feat: Add GitHub update notifications

- Check for new releases from GitHub API with 6-hour cache
- Show toast notification when updates are available
- Add Updates tab in settings for manual checks and preferences
- Support git-based updates with stash handling for local changes
- Persist dismissed versions to avoid repeated notifications

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-01-29 14:51:00 +00:00
parent ae804f92b2
commit 40acca20b2
7 changed files with 2001 additions and 0 deletions

View File

@@ -154,6 +154,11 @@ SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
# Update checking
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
# Admin credentials
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')

139
routes/updater.py Normal file
View File

@@ -0,0 +1,139 @@
"""Updater routes - GitHub update checking and application updates."""
from __future__ import annotations
from flask import Blueprint, jsonify, request, Response
from utils.logging import get_logger
from utils.updater import (
check_for_updates,
get_update_status,
dismiss_update,
perform_update,
)
logger = get_logger('intercept.routes.updater')
updater_bp = Blueprint('updater', __name__, url_prefix='/updater')
@updater_bp.route('/check', methods=['GET'])
def check_updates() -> Response:
"""
Check for updates from GitHub.
Uses caching to avoid excessive API calls. Will only hit GitHub
if the cache is stale (default: 6 hours).
Query parameters:
force: Set to 'true' to bypass cache and check GitHub directly
Returns:
JSON with update status information
"""
force = request.args.get('force', '').lower() == 'true'
try:
result = check_for_updates(force=force)
return jsonify(result)
except Exception as e:
logger.error(f"Error checking for updates: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@updater_bp.route('/status', methods=['GET'])
def update_status() -> Response:
"""
Get current update status from cache.
This endpoint does NOT trigger a GitHub check - it only returns
cached data. Use /check to trigger a fresh check.
Returns:
JSON with cached update status
"""
try:
result = get_update_status()
return jsonify(result)
except Exception as e:
logger.error(f"Error getting update status: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@updater_bp.route('/update', methods=['POST'])
def do_update() -> Response:
"""
Perform a git pull to update the application.
Request body (JSON):
stash_changes: If true, stash local changes before pulling
Returns:
JSON with update result information
"""
data = request.json or {}
stash_changes = data.get('stash_changes', False)
try:
result = perform_update(stash_changes=stash_changes)
if result.get('success'):
return jsonify(result)
else:
# Return appropriate status code based on error type
error = result.get('error', '')
if error == 'local_changes':
return jsonify(result), 409 # Conflict
elif error == 'merge_conflict':
return jsonify(result), 409
elif result.get('manual_update'):
return jsonify(result), 400
else:
return jsonify(result), 500
except Exception as e:
logger.error(f"Error performing update: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@updater_bp.route('/dismiss', methods=['POST'])
def dismiss_notification() -> Response:
"""
Dismiss update notification for a specific version.
The notification will not be shown again until a newer version
is available.
Request body (JSON):
version: The version to dismiss notifications for
Returns:
JSON with success status
"""
data = request.json or {}
version = data.get('version')
if not version:
return jsonify({
'success': False,
'error': 'Version is required'
}), 400
try:
result = dismiss_update(version)
return jsonify(result)
except Exception as e:
logger.error(f"Error dismissing update: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500

View File

@@ -0,0 +1,626 @@
/**
* Toast Notification System
* Reusable toast notifications for update alerts and other messages
*/
/* ============================================
TOAST CONTAINER
============================================ */
#toastContainer {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 10001;
display: flex;
flex-direction: column;
gap: 12px;
pointer-events: none;
}
#toastContainer > * {
pointer-events: auto;
}
/* ============================================
UPDATE TOAST
============================================ */
.update-toast {
display: flex;
background: var(--bg-card, #121620);
border: 1px solid var(--border-color, #1f2937);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
max-width: 340px;
overflow: hidden;
opacity: 0;
transform: translateX(100%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.update-toast.show {
opacity: 1;
transform: translateX(0);
}
.update-toast-indicator {
width: 4px;
background: var(--accent-green, #22c55e);
flex-shrink: 0;
}
.update-toast-content {
flex: 1;
padding: 14px 16px;
}
.update-toast-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.update-toast-icon {
color: var(--accent-green, #22c55e);
display: flex;
align-items: center;
}
.update-toast-icon svg {
width: 18px;
height: 18px;
}
.update-toast-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary, #e8eaed);
flex: 1;
}
.update-toast-close {
background: none;
border: none;
color: var(--text-dim, #4b5563);
font-size: 20px;
line-height: 1;
cursor: pointer;
padding: 0;
margin: -4px;
transition: color 0.15s;
}
.update-toast-close:hover {
color: var(--text-secondary, #9ca3af);
}
.update-toast-body {
font-size: 12px;
color: var(--text-secondary, #9ca3af);
margin-bottom: 12px;
}
.update-toast-body strong {
color: var(--accent-cyan, #4a9eff);
}
.update-toast-actions {
display: flex;
gap: 8px;
}
.update-toast-btn {
font-family: inherit;
font-size: 11px;
font-weight: 500;
padding: 6px 12px;
border-radius: 4px;
border: none;
cursor: pointer;
transition: all 0.15s;
}
.update-toast-btn-primary {
background: var(--accent-green, #22c55e);
color: #000;
}
.update-toast-btn-primary:hover {
background: #34d673;
}
.update-toast-btn-secondary {
background: var(--bg-secondary, #0f1218);
color: var(--text-secondary, #9ca3af);
border: 1px solid var(--border-color, #1f2937);
}
.update-toast-btn-secondary:hover {
background: var(--bg-tertiary, #151a23);
border-color: var(--border-light, #374151);
}
/* ============================================
UPDATE MODAL
============================================ */
.update-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
z-index: 10002;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
}
.update-modal-overlay.show {
opacity: 1;
visibility: visible;
}
.update-modal {
background: var(--bg-card, #121620);
border: 1px solid var(--border-color, #1f2937);
border-radius: 12px;
width: 90%;
max-width: 520px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
transform: scale(0.95);
transition: transform 0.2s ease;
}
.update-modal-overlay.show .update-modal {
transform: scale(1);
}
.update-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color, #1f2937);
}
.update-modal-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #e8eaed);
}
.update-modal-icon {
color: var(--accent-green, #22c55e);
display: flex;
}
.update-modal-icon svg {
width: 22px;
height: 22px;
}
.update-modal-close {
background: none;
border: none;
color: var(--text-dim, #4b5563);
font-size: 24px;
line-height: 1;
cursor: pointer;
padding: 4px;
transition: color 0.15s;
}
.update-modal-close:hover {
color: var(--accent-red, #ef4444);
}
.update-modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
/* Version Info */
.update-version-info {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
padding: 16px;
background: var(--bg-secondary, #0f1218);
border-radius: 8px;
margin-bottom: 20px;
}
.update-version-current,
.update-version-latest {
text-align: center;
}
.update-version-label {
display: block;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim, #4b5563);
margin-bottom: 4px;
}
.update-version-value {
font-family: 'JetBrains Mono', monospace;
font-size: 18px;
font-weight: 600;
color: var(--text-secondary, #9ca3af);
}
.update-version-new {
color: var(--accent-green, #22c55e);
}
.update-version-arrow {
color: var(--text-dim, #4b5563);
}
.update-version-arrow svg {
width: 20px;
height: 20px;
}
/* Sections */
.update-section {
margin-bottom: 20px;
}
.update-section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim, #4b5563);
margin-bottom: 10px;
}
.update-release-notes {
font-size: 13px;
color: var(--text-secondary, #9ca3af);
background: var(--bg-secondary, #0f1218);
border: 1px solid var(--border-color, #1f2937);
border-radius: 6px;
padding: 14px;
max-height: 200px;
overflow-y: auto;
line-height: 1.6;
}
.update-release-notes h2,
.update-release-notes h3,
.update-release-notes h4 {
color: var(--text-primary, #e8eaed);
margin: 16px 0 8px 0;
font-size: 14px;
}
.update-release-notes h2:first-child,
.update-release-notes h3:first-child,
.update-release-notes h4:first-child {
margin-top: 0;
}
.update-release-notes ul {
margin: 8px 0;
padding-left: 20px;
}
.update-release-notes li {
margin: 4px 0;
}
.update-release-notes code {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
background: var(--bg-tertiary, #151a23);
padding: 2px 6px;
border-radius: 3px;
color: var(--accent-cyan, #4a9eff);
}
.update-release-notes p {
margin: 8px 0;
}
/* Warning */
.update-warning {
display: flex;
gap: 12px;
padding: 14px;
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 6px;
margin-bottom: 16px;
}
.update-warning-icon {
color: var(--accent-orange, #f59e0b);
flex-shrink: 0;
}
.update-warning-icon svg {
width: 20px;
height: 20px;
}
.update-warning-text {
font-size: 12px;
color: var(--text-secondary, #9ca3af);
}
.update-warning-text strong {
display: block;
color: var(--accent-orange, #f59e0b);
margin-bottom: 4px;
}
.update-warning-text p {
margin: 0;
}
/* Options */
.update-options {
margin-bottom: 16px;
}
.update-option {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: var(--text-secondary, #9ca3af);
cursor: pointer;
}
.update-option input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent-cyan, #4a9eff);
}
/* Progress */
.update-progress {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 20px;
font-size: 13px;
color: var(--text-secondary, #9ca3af);
}
.update-progress-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-color, #1f2937);
border-top-color: var(--accent-cyan, #4a9eff);
border-radius: 50%;
animation: updateSpin 0.8s linear infinite;
}
@keyframes updateSpin {
to { transform: rotate(360deg); }
}
/* Results */
.update-result {
display: flex;
gap: 12px;
padding: 14px;
border-radius: 6px;
margin-top: 16px;
}
.update-result-icon {
flex-shrink: 0;
}
.update-result-icon svg {
width: 20px;
height: 20px;
}
.update-result-text {
font-size: 12px;
line-height: 1.5;
}
.update-result-text code {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
background: rgba(0, 0, 0, 0.2);
padding: 2px 6px;
border-radius: 3px;
display: inline-block;
word-break: break-all;
}
.update-result-success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
}
.update-result-success .update-result-icon {
color: var(--accent-green, #22c55e);
}
.update-result-success .update-result-text {
color: var(--accent-green, #22c55e);
}
.update-result-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
}
.update-result-error .update-result-icon {
color: var(--accent-red, #ef4444);
}
.update-result-error .update-result-text {
color: var(--text-secondary, #9ca3af);
}
.update-result-error .update-result-text strong {
color: var(--accent-red, #ef4444);
}
.update-result-warning {
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
}
.update-result-warning .update-result-icon {
color: var(--accent-orange, #f59e0b);
}
.update-result-warning .update-result-text {
color: var(--text-secondary, #9ca3af);
}
.update-result-warning .update-result-text strong {
color: var(--accent-orange, #f59e0b);
}
.update-result-info {
background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.3);
}
.update-result-info .update-result-icon {
color: var(--accent-cyan, #4a9eff);
}
.update-result-info .update-result-text {
color: var(--text-secondary, #9ca3af);
}
/* Footer */
.update-modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-top: 1px solid var(--border-color, #1f2937);
background: var(--bg-secondary, #0f1218);
border-radius: 0 0 12px 12px;
}
.update-modal-link {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-dim, #4b5563);
text-decoration: none;
transition: color 0.15s;
}
.update-modal-link:hover {
color: var(--accent-cyan, #4a9eff);
}
.update-modal-actions {
display: flex;
gap: 10px;
}
.update-modal-btn {
font-family: inherit;
font-size: 12px;
font-weight: 500;
padding: 8px 16px;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s;
}
.update-modal-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.update-modal-btn-primary {
background: var(--accent-green, #22c55e);
color: #000;
}
.update-modal-btn-primary:hover:not(:disabled) {
background: #34d673;
}
.update-modal-btn-secondary {
background: var(--bg-tertiary, #151a23);
color: var(--text-secondary, #9ca3af);
border: 1px solid var(--border-color, #1f2937);
}
.update-modal-btn-secondary:hover:not(:disabled) {
background: var(--bg-elevated, #1a202c);
border-color: var(--border-light, #374151);
}
/* ============================================
RESPONSIVE
============================================ */
@media (max-width: 480px) {
#toastContainer {
bottom: 10px;
right: 10px;
left: 10px;
}
.update-toast {
max-width: none;
}
.update-modal {
width: 95%;
max-height: 90vh;
}
.update-version-info {
flex-direction: column;
gap: 10px;
}
.update-version-arrow {
transform: rotate(90deg);
}
.update-modal-footer {
flex-direction: column;
gap: 12px;
}
.update-modal-link {
order: 2;
}
.update-modal-actions {
width: 100%;
}
.update-modal-btn {
flex: 1;
}
}

View File

@@ -539,3 +539,175 @@ function loadSettingsTools() {
document.addEventListener('DOMContentLoaded', () => {
Settings.init();
});
// =============================================================================
// Update Settings Functions
// =============================================================================
/**
* Check for updates manually from settings panel
*/
async function checkForUpdatesManual() {
const content = document.getElementById('updateStatusContent');
if (!content) return;
content.innerHTML = '<div style="text-align: center; padding: 20px; color: var(--text-dim);">Checking for updates...</div>';
try {
const data = await Updater.checkNow();
renderUpdateStatus(data);
} catch (error) {
content.innerHTML = `<div style="color: var(--accent-red); padding: 10px;">Error checking for updates: ${error.message}</div>`;
}
}
/**
* Load update status when tab is opened
*/
async function loadUpdateStatus() {
const content = document.getElementById('updateStatusContent');
if (!content) return;
try {
const data = await Updater.getStatus();
renderUpdateStatus(data);
} catch (error) {
content.innerHTML = `<div style="color: var(--accent-red); padding: 10px;">Error loading update status: ${error.message}</div>`;
}
}
/**
* Render update status in settings panel
*/
function renderUpdateStatus(data) {
const content = document.getElementById('updateStatusContent');
if (!content) return;
if (!data.success) {
content.innerHTML = `<div style="color: var(--accent-red); padding: 10px;">Error: ${data.error || 'Unknown error'}</div>`;
return;
}
if (data.disabled) {
content.innerHTML = `
<div style="padding: 15px; background: var(--bg-tertiary); border-radius: 6px; text-align: center;">
<div style="color: var(--text-dim); font-size: 13px;">Update checking is disabled</div>
</div>
`;
return;
}
if (!data.checked) {
content.innerHTML = `
<div style="padding: 15px; background: var(--bg-tertiary); border-radius: 6px; text-align: center;">
<div style="color: var(--text-dim); font-size: 13px;">No update check performed yet</div>
<div style="font-size: 11px; color: var(--text-dim); margin-top: 5px;">Click "Check Now" to check for updates</div>
</div>
`;
return;
}
const statusColor = data.update_available ? 'var(--accent-green)' : 'var(--text-dim)';
const statusText = data.update_available ? 'Update Available' : 'Up to Date';
const statusIcon = data.update_available
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>'
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>';
let html = `
<div style="padding: 15px; background: var(--bg-tertiary); border-radius: 6px; border-left: 3px solid ${statusColor};">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
<span style="color: ${statusColor};">${statusIcon}</span>
<span style="font-weight: 600; color: ${statusColor};">${statusText}</span>
</div>
<div style="display: grid; gap: 8px; font-size: 12px;">
<div style="display: flex; justify-content: space-between;">
<span style="color: var(--text-dim);">Current Version</span>
<span style="font-family: 'JetBrains Mono', monospace; color: var(--text-primary);">v${data.current_version}</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span style="color: var(--text-dim);">Latest Version</span>
<span style="font-family: 'JetBrains Mono', monospace; color: ${data.update_available ? 'var(--accent-green)' : 'var(--text-primary)'};">v${data.latest_version}</span>
</div>
${data.last_check ? `
<div style="display: flex; justify-content: space-between;">
<span style="color: var(--text-dim);">Last Checked</span>
<span style="color: var(--text-secondary);">${formatLastCheck(data.last_check)}</span>
</div>
` : ''}
</div>
${data.update_available ? `
<button onclick="Updater.showUpdateModal()" style="
margin-top: 12px;
width: 100%;
padding: 8px;
background: var(--accent-green);
color: #000;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
">View Update Details</button>
` : ''}
</div>
`;
content.innerHTML = html;
}
/**
* Format last check timestamp
*/
function formatLastCheck(isoString) {
try {
const date = new Date(isoString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins} min ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
return date.toLocaleDateString();
} catch (e) {
return isoString;
}
}
/**
* Toggle update checking
*/
async function toggleUpdateCheck(enabled) {
// This would require adding a setting to disable update checks
// For now, just store in localStorage
localStorage.setItem('intercept_update_check_enabled', enabled ? 'true' : 'false');
if (!enabled && typeof Updater !== 'undefined') {
Updater.destroy();
} else if (enabled && typeof Updater !== 'undefined') {
Updater.init();
}
}
// Extend switchSettingsTab to load update status
const _originalSwitchSettingsTab = typeof switchSettingsTab !== 'undefined' ? switchSettingsTab : null;
function switchSettingsTab(tabName) {
// Update tab buttons
document.querySelectorAll('.settings-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.tab === tabName);
});
// Update sections
document.querySelectorAll('.settings-section').forEach(section => {
section.classList.toggle('active', section.id === `settings-${tabName}`);
});
// Load content based on tab
if (tabName === 'tools') {
loadSettingsTools();
} else if (tabName === 'updates') {
loadUpdateStatus();
}
}

498
static/js/core/updater.js Normal file
View File

@@ -0,0 +1,498 @@
/**
* Updater Module - GitHub update checking and notification system
*/
const Updater = {
// State
_checkInterval: null,
_toastElement: null,
_modalElement: null,
_updateData: null,
// Configuration
CHECK_INTERVAL_MS: 6 * 60 * 60 * 1000, // 6 hours in milliseconds
/**
* Initialize the updater module
*/
init() {
// Create toast container if it doesn't exist
this._ensureToastContainer();
// Check for updates on page load
this.checkForUpdates();
// Set up periodic checks
this._checkInterval = setInterval(() => {
this.checkForUpdates();
}, this.CHECK_INTERVAL_MS);
},
/**
* Ensure toast container exists in DOM
*/
_ensureToastContainer() {
if (!document.getElementById('toastContainer')) {
const container = document.createElement('div');
container.id = 'toastContainer';
document.body.appendChild(container);
}
},
/**
* Check for updates from the server
* @param {boolean} force - Bypass cache and check GitHub directly
*/
async checkForUpdates(force = false) {
try {
const url = force ? '/updater/check?force=true' : '/updater/check';
const response = await fetch(url);
const data = await response.json();
if (data.success && data.show_notification) {
this._updateData = data;
this.showUpdateToast(data);
}
return data;
} catch (error) {
console.warn('Failed to check for updates:', error);
return { success: false, error: error.message };
}
},
/**
* Get cached update status without triggering a check
*/
async getStatus() {
try {
const response = await fetch('/updater/status');
return await response.json();
} catch (error) {
console.warn('Failed to get update status:', error);
return { success: false, error: error.message };
}
},
/**
* Show update toast notification
* @param {Object} data - Update data from server
*/
showUpdateToast(data) {
// Remove existing toast if present
this.hideToast();
const toast = document.createElement('div');
toast.className = 'update-toast';
toast.innerHTML = `
<div class="update-toast-indicator"></div>
<div class="update-toast-content">
<div class="update-toast-header">
<span class="update-toast-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</span>
<span class="update-toast-title">Update Available</span>
<button class="update-toast-close" onclick="Updater.dismissUpdate()">&times;</button>
</div>
<div class="update-toast-body">
Version <strong>${data.latest_version}</strong> is ready
</div>
<div class="update-toast-actions">
<button class="update-toast-btn update-toast-btn-primary" onclick="Updater.showUpdateModal()">
View Details
</button>
<button class="update-toast-btn update-toast-btn-secondary" onclick="Updater.hideToast()">
Later
</button>
</div>
</div>
`;
const container = document.getElementById('toastContainer');
if (container) {
container.appendChild(toast);
} else {
document.body.appendChild(toast);
}
this._toastElement = toast;
// Trigger animation
requestAnimationFrame(() => {
toast.classList.add('show');
});
},
/**
* Hide the update toast
*/
hideToast() {
if (this._toastElement) {
this._toastElement.classList.remove('show');
setTimeout(() => {
if (this._toastElement && this._toastElement.parentNode) {
this._toastElement.parentNode.removeChild(this._toastElement);
}
this._toastElement = null;
}, 300);
}
},
/**
* Dismiss update notification for this version
*/
async dismissUpdate() {
this.hideToast();
if (this._updateData && this._updateData.latest_version) {
try {
await fetch('/updater/dismiss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version: this._updateData.latest_version })
});
} catch (error) {
console.warn('Failed to dismiss update:', error);
}
}
},
/**
* Show the full update modal with details
*/
showUpdateModal() {
this.hideToast();
if (!this._updateData) {
console.warn('No update data available');
return;
}
// Remove existing modal if present
this.hideModal();
const data = this._updateData;
const releaseNotes = this._formatReleaseNotes(data.release_notes || 'No release notes available.');
const modal = document.createElement('div');
modal.className = 'update-modal-overlay';
modal.onclick = (e) => {
if (e.target === modal) this.hideModal();
};
modal.innerHTML = `
<div class="update-modal">
<div class="update-modal-header">
<div class="update-modal-title">
<span class="update-modal-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</span>
Update Available
</div>
<button class="update-modal-close" onclick="Updater.hideModal()">&times;</button>
</div>
<div class="update-modal-body">
<div class="update-version-info">
<div class="update-version-current">
<span class="update-version-label">Current</span>
<span class="update-version-value">v${data.current_version}</span>
</div>
<div class="update-version-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/>
</svg>
</div>
<div class="update-version-latest">
<span class="update-version-label">Latest</span>
<span class="update-version-value update-version-new">v${data.latest_version}</span>
</div>
</div>
<div class="update-section">
<div class="update-section-title">Release Notes</div>
<div class="update-release-notes">${releaseNotes}</div>
</div>
<div class="update-warning" id="updateWarning" style="display: none;">
<div class="update-warning-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</div>
<div class="update-warning-text">
<strong>Local changes detected</strong>
<p id="updateWarningText"></p>
</div>
</div>
<div class="update-options" id="updateOptions" style="display: none;">
<label class="update-option">
<input type="checkbox" id="stashChanges">
<span>Stash local changes before updating</span>
</label>
</div>
<div class="update-progress" id="updateProgress" style="display: none;">
<div class="update-progress-spinner"></div>
<span id="updateProgressText">Updating...</span>
</div>
<div class="update-result" id="updateResult" style="display: none;"></div>
</div>
<div class="update-modal-footer">
<a href="${data.release_url || '#'}" target="_blank" class="update-modal-link" ${!data.release_url ? 'style="display:none"' : ''}>
View on GitHub
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
<div class="update-modal-actions">
<button class="update-modal-btn update-modal-btn-secondary" onclick="Updater.hideModal()">
Cancel
</button>
<button class="update-modal-btn update-modal-btn-primary" id="updateNowBtn" onclick="Updater.performUpdate()">
Update Now
</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
this._modalElement = modal;
// Trigger animation
requestAnimationFrame(() => {
modal.classList.add('show');
});
},
/**
* Hide the update modal
*/
hideModal() {
if (this._modalElement) {
this._modalElement.classList.remove('show');
setTimeout(() => {
if (this._modalElement && this._modalElement.parentNode) {
this._modalElement.parentNode.removeChild(this._modalElement);
}
this._modalElement = null;
}, 200);
}
},
/**
* Perform the update
*/
async performUpdate() {
const progressEl = document.getElementById('updateProgress');
const progressText = document.getElementById('updateProgressText');
const resultEl = document.getElementById('updateResult');
const updateBtn = document.getElementById('updateNowBtn');
const warningEl = document.getElementById('updateWarning');
const optionsEl = document.getElementById('updateOptions');
const stashCheckbox = document.getElementById('stashChanges');
// Show progress
if (progressEl) progressEl.style.display = 'flex';
if (progressText) progressText.textContent = 'Checking repository status...';
if (updateBtn) updateBtn.disabled = true;
if (resultEl) resultEl.style.display = 'none';
try {
const stashChanges = stashCheckbox ? stashCheckbox.checked : false;
if (progressText) progressText.textContent = 'Fetching and applying updates...';
const response = await fetch('/updater/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stash_changes: stashChanges })
});
const data = await response.json();
if (progressEl) progressEl.style.display = 'none';
if (data.success) {
this._showResult(resultEl, true, data);
} else {
// Handle specific error cases
if (data.error === 'local_changes') {
if (warningEl) {
warningEl.style.display = 'flex';
const warningText = document.getElementById('updateWarningText');
if (warningText) {
warningText.textContent = data.message;
}
}
if (optionsEl) optionsEl.style.display = 'block';
if (updateBtn) updateBtn.disabled = false;
} else if (data.manual_update) {
this._showResult(resultEl, false, data, true);
} else {
this._showResult(resultEl, false, data);
}
}
} catch (error) {
if (progressEl) progressEl.style.display = 'none';
this._showResult(resultEl, false, { error: error.message });
}
},
/**
* Show update result
*/
_showResult(resultEl, success, data, isManual = false) {
if (!resultEl) return;
resultEl.style.display = 'block';
if (success) {
if (data.updated) {
let message = '<strong>Update successful!</strong><br>Please restart the application to complete the update.';
if (data.requirements_changed) {
message += '<br><br><strong>Dependencies changed!</strong> Run:<br><code>pip install -r requirements.txt</code>';
}
resultEl.className = 'update-result update-result-success';
resultEl.innerHTML = `
<div class="update-result-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div class="update-result-text">${message}</div>
`;
} else {
resultEl.className = 'update-result update-result-info';
resultEl.innerHTML = `
<div class="update-result-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
</div>
<div class="update-result-text">${data.message || 'Already up to date.'}</div>
`;
}
} else {
if (isManual) {
resultEl.className = 'update-result update-result-warning';
resultEl.innerHTML = `
<div class="update-result-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</div>
<div class="update-result-text">
<strong>Manual update required</strong><br>
${data.message || 'Please download the latest release from GitHub.'}
</div>
`;
} else {
resultEl.className = 'update-result update-result-error';
resultEl.innerHTML = `
<div class="update-result-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
</div>
<div class="update-result-text">
<strong>Update failed</strong><br>
${data.message || data.error || 'An error occurred during the update.'}
${data.details ? '<br><code style="font-size: 10px; margin-top: 8px; display: block;">' + data.details.substring(0, 200) + '</code>' : ''}
</div>
`;
}
}
},
/**
* Format release notes (basic markdown to HTML)
*/
_formatReleaseNotes(notes) {
if (!notes) return '<p>No release notes available.</p>';
// Escape HTML
let html = notes
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Convert markdown-style formatting
html = html
// Headers
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
// Bold
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// Italic
.replace(/\*(.+?)\*/g, '<em>$1</em>')
// Code
.replace(/`(.+?)`/g, '<code>$1</code>')
// Lists
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/^(\d+)\. (.+)$/gm, '<li>$2</li>')
// Paragraphs
.replace(/\n\n/g, '</p><p>')
// Line breaks
.replace(/\n/g, '<br>');
// Wrap list items
html = html.replace(/(<li>.*<\/li>)+/g, '<ul>$&</ul>');
return '<p>' + html + '</p>';
},
/**
* Manual trigger for settings panel
*/
async checkNow() {
return await this.checkForUpdates(true);
},
/**
* Clean up on page unload
*/
destroy() {
if (this._checkInterval) {
clearInterval(this._checkInterval);
this._checkInterval = null;
}
this.hideToast();
this.hideModal();
}
};
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', () => {
Updater.init();
});
// Clean up on page unload
window.addEventListener('beforeunload', () => {
Updater.destroy();
});

View File

@@ -12,6 +12,7 @@
<div class="settings-tabs">
<button class="settings-tab active" data-tab="offline" onclick="switchSettingsTab('offline')">Offline</button>
<button class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')">Display</button>
<button class="settings-tab" data-tab="updates" onclick="switchSettingsTab('updates')">Updates</button>
<button class="settings-tab" data-tab="tools" onclick="switchSettingsTab('tools')">Tools</button>
<button class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')">About</button>
</div>
@@ -147,6 +148,41 @@
</div>
</div>
<!-- Updates Section -->
<div id="settings-updates" class="settings-section">
<div class="settings-group">
<div class="settings-group-title">Update Status</div>
<div id="updateStatusContent" style="padding: 10px 0;">
<div style="text-align: center; padding: 20px; color: var(--text-dim);">
Loading update status...
</div>
</div>
<button class="check-assets-btn" onclick="checkForUpdatesManual()" style="margin-top: 10px;">
Check Now
</button>
</div>
<div class="settings-group">
<div class="settings-group-title">Update Settings</div>
<div class="settings-row">
<div class="settings-label">
<span class="settings-label-text">Auto-Check for Updates</span>
<span class="settings-label-desc">Periodically check GitHub for new releases</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="updateCheckEnabled" checked onchange="toggleUpdateCheck(this.checked)">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="settings-info">
<strong>Note:</strong> Updates are fetched from GitHub and applied via git pull.
Make sure you have git installed and the application is in a git repository.
</div>
</div>
<!-- Tools Section -->
<div id="settings-tools" class="settings-section">
<div class="settings-group">

525
utils/updater.py Normal file
View File

@@ -0,0 +1,525 @@
"""
GitHub update checking and git-based update mechanism.
"""
from __future__ import annotations
import json
import logging
import os
import re
import subprocess
import time
from datetime import datetime
from typing import Any
from urllib.request import urlopen, Request
from urllib.error import URLError, HTTPError
import config
from utils.database import get_setting, set_setting
logger = logging.getLogger('intercept.updater')
# Cache keys for settings
CACHE_KEY_LAST_CHECK = 'update.last_check'
CACHE_KEY_LATEST_VERSION = 'update.latest_version'
CACHE_KEY_RELEASE_URL = 'update.release_url'
CACHE_KEY_RELEASE_NOTES = 'update.release_notes'
CACHE_KEY_DISMISSED_VERSION = 'update.dismissed_version'
# Default check interval (6 hours in seconds)
DEFAULT_CHECK_INTERVAL = 6 * 60 * 60
def _get_github_repo() -> str:
"""Get the configured GitHub repository."""
return getattr(config, 'GITHUB_REPO', 'smittix/intercept')
def _get_check_interval() -> int:
"""Get the configured check interval in seconds."""
hours = getattr(config, 'UPDATE_CHECK_INTERVAL_HOURS', 6)
return hours * 60 * 60
def _is_update_check_enabled() -> bool:
"""Check if update checking is enabled."""
return getattr(config, 'UPDATE_CHECK_ENABLED', True)
def _compare_versions(current: str, latest: str) -> int:
"""
Compare two semantic version strings.
Returns:
-1 if current < latest (update available)
0 if current == latest
1 if current > latest
"""
def parse_version(v: str) -> tuple:
# Strip 'v' prefix if present
v = v.lstrip('v')
# Split by dots and convert to integers
parts = []
for part in v.split('.'):
# Handle pre-release suffixes like 2.11.0-beta
match = re.match(r'^(\d+)', part)
if match:
parts.append(int(match.group(1)))
else:
parts.append(0)
# Pad to at least 3 parts
while len(parts) < 3:
parts.append(0)
return tuple(parts)
try:
current_parts = parse_version(current)
latest_parts = parse_version(latest)
if current_parts < latest_parts:
return -1
elif current_parts > latest_parts:
return 1
return 0
except Exception as e:
logger.warning(f"Error comparing versions '{current}' and '{latest}': {e}")
return 0
def _fetch_github_release() -> dict[str, Any] | None:
"""
Fetch the latest release from GitHub API.
Returns:
Dict with release info or None on error
"""
repo = _get_github_repo()
url = f'https://api.github.com/repos/{repo}/releases/latest'
try:
req = Request(url, headers={
'User-Agent': 'Intercept-SIGINT',
'Accept': 'application/vnd.github.v3+json'
})
with urlopen(req, timeout=10) as response:
# Check rate limit headers
remaining = response.headers.get('X-RateLimit-Remaining')
if remaining and int(remaining) < 10:
logger.warning(f"GitHub API rate limit low: {remaining} remaining")
data = json.loads(response.read().decode('utf-8'))
return {
'tag_name': data.get('tag_name', ''),
'html_url': data.get('html_url', ''),
'body': data.get('body', ''),
'published_at': data.get('published_at', ''),
'name': data.get('name', '')
}
except HTTPError as e:
if e.code == 404:
logger.info("No releases found on GitHub")
else:
logger.warning(f"GitHub API error: {e.code} {e.reason}")
return None
except URLError as e:
logger.warning(f"Failed to fetch GitHub release: {e}")
return None
except Exception as e:
logger.warning(f"Error fetching GitHub release: {e}")
return None
def check_for_updates(force: bool = False) -> dict[str, Any]:
"""
Check GitHub for updates.
Uses caching to avoid excessive API calls. Only checks GitHub if:
- force=True, or
- Last check was more than check_interval ago
Args:
force: If True, bypass cache and check GitHub directly
Returns:
Dict with update status information
"""
if not _is_update_check_enabled():
return {
'success': True,
'update_available': False,
'disabled': True,
'message': 'Update checking is disabled'
}
current_version = config.VERSION
# Check cache unless forced
if not force:
last_check = get_setting(CACHE_KEY_LAST_CHECK)
if last_check:
try:
last_check_time = float(last_check)
check_interval = _get_check_interval()
if time.time() - last_check_time < check_interval:
# Return cached data
cached_version = get_setting(CACHE_KEY_LATEST_VERSION)
if cached_version:
dismissed = get_setting(CACHE_KEY_DISMISSED_VERSION)
update_available = _compare_versions(current_version, cached_version) < 0
# Don't show update if user dismissed this version
show_notification = update_available and dismissed != cached_version
return {
'success': True,
'update_available': update_available,
'show_notification': show_notification,
'current_version': current_version,
'latest_version': cached_version,
'release_url': get_setting(CACHE_KEY_RELEASE_URL) or '',
'release_notes': get_setting(CACHE_KEY_RELEASE_NOTES) or '',
'cached': True,
'last_check': datetime.fromtimestamp(last_check_time).isoformat()
}
except (ValueError, TypeError):
pass
# Fetch from GitHub
release = _fetch_github_release()
if not release:
# Return cached data if available, otherwise error
cached_version = get_setting(CACHE_KEY_LATEST_VERSION)
if cached_version:
update_available = _compare_versions(current_version, cached_version) < 0
return {
'success': True,
'update_available': update_available,
'current_version': current_version,
'latest_version': cached_version,
'release_url': get_setting(CACHE_KEY_RELEASE_URL) or '',
'release_notes': get_setting(CACHE_KEY_RELEASE_NOTES) or '',
'cached': True,
'network_error': True
}
return {
'success': False,
'error': 'Failed to check for updates'
}
latest_version = release['tag_name'].lstrip('v')
# Update cache
set_setting(CACHE_KEY_LAST_CHECK, str(time.time()))
set_setting(CACHE_KEY_LATEST_VERSION, latest_version)
set_setting(CACHE_KEY_RELEASE_URL, release['html_url'])
set_setting(CACHE_KEY_RELEASE_NOTES, release['body'][:2000] if release['body'] else '')
update_available = _compare_versions(current_version, latest_version) < 0
dismissed = get_setting(CACHE_KEY_DISMISSED_VERSION)
show_notification = update_available and dismissed != latest_version
return {
'success': True,
'update_available': update_available,
'show_notification': show_notification,
'current_version': current_version,
'latest_version': latest_version,
'release_url': release['html_url'],
'release_notes': release['body'] or '',
'release_name': release['name'] or f'v{latest_version}',
'published_at': release['published_at'],
'cached': False
}
def get_update_status() -> dict[str, Any]:
"""
Get current update status from cache without triggering a check.
Returns:
Dict with cached update status
"""
current_version = config.VERSION
cached_version = get_setting(CACHE_KEY_LATEST_VERSION)
last_check = get_setting(CACHE_KEY_LAST_CHECK)
dismissed = get_setting(CACHE_KEY_DISMISSED_VERSION)
if not cached_version:
return {
'success': True,
'checked': False,
'current_version': current_version
}
update_available = _compare_versions(current_version, cached_version) < 0
show_notification = update_available and dismissed != cached_version
last_check_time = None
if last_check:
try:
last_check_time = datetime.fromtimestamp(float(last_check)).isoformat()
except (ValueError, TypeError):
pass
return {
'success': True,
'checked': True,
'update_available': update_available,
'show_notification': show_notification,
'current_version': current_version,
'latest_version': cached_version,
'release_url': get_setting(CACHE_KEY_RELEASE_URL) or '',
'release_notes': get_setting(CACHE_KEY_RELEASE_NOTES) or '',
'dismissed_version': dismissed,
'last_check': last_check_time
}
def dismiss_update(version: str) -> dict[str, Any]:
"""
Dismiss update notification for a specific version.
Args:
version: The version to dismiss
Returns:
Status dict
"""
set_setting(CACHE_KEY_DISMISSED_VERSION, version)
return {
'success': True,
'dismissed_version': version
}
def _is_git_repo() -> bool:
"""Check if the current directory is a git repository."""
try:
result = subprocess.run(
['git', 'rev-parse', '--is-inside-work-tree'],
capture_output=True,
text=True,
timeout=5,
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
return result.returncode == 0 and result.stdout.strip() == 'true'
except Exception:
return False
def _get_git_status() -> dict[str, Any]:
"""Get git repository status."""
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
try:
# Check for uncommitted changes
result = subprocess.run(
['git', 'status', '--porcelain'],
capture_output=True,
text=True,
timeout=10,
cwd=repo_root
)
has_changes = bool(result.stdout.strip())
changed_files = result.stdout.strip().split('\n') if has_changes else []
# Get current branch
branch_result = subprocess.run(
['git', 'branch', '--show-current'],
capture_output=True,
text=True,
timeout=5,
cwd=repo_root
)
current_branch = branch_result.stdout.strip() or 'main'
return {
'has_changes': has_changes,
'changed_files': [f for f in changed_files if f],
'current_branch': current_branch
}
except Exception as e:
logger.warning(f"Error getting git status: {e}")
return {
'has_changes': False,
'changed_files': [],
'current_branch': 'unknown',
'error': str(e)
}
def perform_update(stash_changes: bool = False) -> dict[str, Any]:
"""
Perform a git pull to update the application.
Args:
stash_changes: If True, stash local changes before pulling
Returns:
Dict with update result information
"""
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Check if this is a git repo
if not _is_git_repo():
return {
'success': False,
'error': 'Not a git repository',
'manual_update': True,
'message': 'This installation is not using git. Please update manually by downloading the latest release from GitHub.'
}
git_status = _get_git_status()
# Check for local changes
if git_status['has_changes'] and not stash_changes:
return {
'success': False,
'error': 'local_changes',
'message': 'You have uncommitted local changes. Either commit them, discard them, or enable "stash changes" to temporarily save them.',
'changed_files': git_status['changed_files']
}
try:
# Stash changes if requested
stashed = False
if stash_changes and git_status['has_changes']:
stash_result = subprocess.run(
['git', 'stash', 'push', '-m', 'INTERCEPT auto-stash before update'],
capture_output=True,
text=True,
timeout=30,
cwd=repo_root
)
if stash_result.returncode == 0:
stashed = True
logger.info("Stashed local changes before update")
else:
return {
'success': False,
'error': 'Failed to stash changes',
'details': stash_result.stderr
}
# Get current requirements.txt hash to detect changes
req_path = os.path.join(repo_root, 'requirements.txt')
req_hash_before = None
if os.path.exists(req_path):
with open(req_path, 'rb') as f:
import hashlib
req_hash_before = hashlib.md5(f.read()).hexdigest()
# Fetch latest changes
fetch_result = subprocess.run(
['git', 'fetch', 'origin'],
capture_output=True,
text=True,
timeout=60,
cwd=repo_root
)
if fetch_result.returncode != 0:
# Restore stash if we stashed
if stashed:
subprocess.run(['git', 'stash', 'pop'], cwd=repo_root, timeout=30)
return {
'success': False,
'error': 'Failed to fetch updates',
'details': fetch_result.stderr
}
# Get the main branch name
branch = git_status.get('current_branch', 'main')
# Pull changes
pull_result = subprocess.run(
['git', 'pull', 'origin', branch],
capture_output=True,
text=True,
timeout=120,
cwd=repo_root
)
if pull_result.returncode != 0:
# Check for merge conflict
if 'CONFLICT' in pull_result.stdout or 'CONFLICT' in pull_result.stderr:
# Abort merge
subprocess.run(['git', 'merge', '--abort'], cwd=repo_root, timeout=30)
# Restore stash if we stashed
if stashed:
subprocess.run(['git', 'stash', 'pop'], cwd=repo_root, timeout=30)
return {
'success': False,
'error': 'merge_conflict',
'message': 'Merge conflict detected. The update was aborted. Please resolve conflicts manually or reset to a clean state.',
'details': pull_result.stdout + pull_result.stderr
}
# Restore stash if we stashed
if stashed:
subprocess.run(['git', 'stash', 'pop'], cwd=repo_root, timeout=30)
return {
'success': False,
'error': 'Failed to pull updates',
'details': pull_result.stderr
}
# Restore stashed changes
if stashed:
stash_pop_result = subprocess.run(
['git', 'stash', 'pop'],
capture_output=True,
text=True,
timeout=30,
cwd=repo_root
)
if stash_pop_result.returncode != 0:
logger.warning(f"Failed to restore stashed changes: {stash_pop_result.stderr}")
# Check if requirements changed
requirements_changed = False
if req_hash_before and os.path.exists(req_path):
with open(req_path, 'rb') as f:
import hashlib
req_hash_after = hashlib.md5(f.read()).hexdigest()
requirements_changed = req_hash_before != req_hash_after
# Determine if update actually happened
if 'Already up to date' in pull_result.stdout:
return {
'success': True,
'updated': False,
'message': 'Already up to date',
'stashed': stashed
}
# Clear update cache to reflect new version
set_setting(CACHE_KEY_LAST_CHECK, '')
set_setting(CACHE_KEY_LATEST_VERSION, '')
return {
'success': True,
'updated': True,
'message': 'Update successful! Please restart the application.',
'requirements_changed': requirements_changed,
'stashed': stashed,
'stash_restored': stashed,
'output': pull_result.stdout
}
except subprocess.TimeoutExpired:
return {
'success': False,
'error': 'Operation timed out',
'message': 'The update operation timed out. Please check your network connection and try again.'
}
except Exception as e:
logger.error(f"Update error: {e}")
return {
'success': False,
'error': str(e)
}