diff --git a/config.py b/config.py
index 18c9280..6406039 100644
--- a/config.py
+++ b/config.py
@@ -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')
diff --git a/routes/updater.py b/routes/updater.py
new file mode 100644
index 0000000..f80d8c4
--- /dev/null
+++ b/routes/updater.py
@@ -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
diff --git a/static/css/components/toast.css b/static/css/components/toast.css
new file mode 100644
index 0000000..31b9da4
--- /dev/null
+++ b/static/css/components/toast.css
@@ -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;
+ }
+}
diff --git a/static/js/core/settings-manager.js b/static/js/core/settings-manager.js
index d4593af..290d1e5 100644
--- a/static/js/core/settings-manager.js
+++ b/static/js/core/settings-manager.js
@@ -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 = '
Checking for updates...
';
+
+ try {
+ const data = await Updater.checkNow();
+ renderUpdateStatus(data);
+ } catch (error) {
+ content.innerHTML = `Error checking for updates: ${error.message}
`;
+ }
+}
+
+/**
+ * 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 = `Error loading update status: ${error.message}
`;
+ }
+}
+
+/**
+ * Render update status in settings panel
+ */
+function renderUpdateStatus(data) {
+ const content = document.getElementById('updateStatusContent');
+ if (!content) return;
+
+ if (!data.success) {
+ content.innerHTML = `Error: ${data.error || 'Unknown error'}
`;
+ return;
+ }
+
+ if (data.disabled) {
+ content.innerHTML = `
+
+
Update checking is disabled
+
+ `;
+ return;
+ }
+
+ if (!data.checked) {
+ content.innerHTML = `
+
+
No update check performed yet
+
Click "Check Now" to check for updates
+
+ `;
+ 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
+ ? ''
+ : '';
+
+ let html = `
+
+
+ ${statusIcon}
+ ${statusText}
+
+
+
+ Current Version
+ v${data.current_version}
+
+
+ Latest Version
+ v${data.latest_version}
+
+ ${data.last_check ? `
+
+ Last Checked
+ ${formatLastCheck(data.last_check)}
+
+ ` : ''}
+
+ ${data.update_available ? `
+
+ ` : ''}
+
+ `;
+
+ 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();
+ }
+}
diff --git a/static/js/core/updater.js b/static/js/core/updater.js
new file mode 100644
index 0000000..d14b63f
--- /dev/null
+++ b/static/js/core/updater.js
@@ -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 = `
+
+
+
+
+ Version ${data.latest_version} is ready
+
+
+
+
+
+
+ `;
+
+ 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 = `
+
+
+
+
+
+ Current
+ v${data.current_version}
+
+
+
+ Latest
+ v${data.latest_version}
+
+
+
+
+
Release Notes
+
${releaseNotes}
+
+
+
+
+
+
Local changes detected
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ 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 = 'Update successful!
Please restart the application to complete the update.';
+
+ if (data.requirements_changed) {
+ message += '
Dependencies changed! Run:
pip install -r requirements.txt';
+ }
+
+ resultEl.className = 'update-result update-result-success';
+ resultEl.innerHTML = `
+
+ ${message}
+ `;
+ } else {
+ resultEl.className = 'update-result update-result-info';
+ resultEl.innerHTML = `
+
+
+
+ ${data.message || 'Already up to date.'}
+ `;
+ }
+ } else {
+ if (isManual) {
+ resultEl.className = 'update-result update-result-warning';
+ resultEl.innerHTML = `
+
+
+ Manual update required
+ ${data.message || 'Please download the latest release from GitHub.'}
+
+ `;
+ } else {
+ resultEl.className = 'update-result update-result-error';
+ resultEl.innerHTML = `
+
+
+
+
+ Update failed
+ ${data.message || data.error || 'An error occurred during the update.'}
+ ${data.details ? '
' + data.details.substring(0, 200) + '' : ''}
+
+ `;
+ }
+ }
+ },
+
+ /**
+ * Format release notes (basic markdown to HTML)
+ */
+ _formatReleaseNotes(notes) {
+ if (!notes) return 'No release notes available.
';
+
+ // Escape HTML
+ let html = notes
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+
+ // Convert markdown-style formatting
+ html = html
+ // Headers
+ .replace(/^### (.+)$/gm, '$1
')
+ .replace(/^## (.+)$/gm, '$1
')
+ .replace(/^# (.+)$/gm, '$1
')
+ // Bold
+ .replace(/\*\*(.+?)\*\*/g, '$1')
+ // Italic
+ .replace(/\*(.+?)\*/g, '$1')
+ // Code
+ .replace(/`(.+?)`/g, '$1')
+ // Lists
+ .replace(/^- (.+)$/gm, '$1')
+ .replace(/^(\d+)\. (.+)$/gm, '$2')
+ // Paragraphs
+ .replace(/\n\n/g, '')
+ // Line breaks
+ .replace(/\n/g, '
');
+
+ // Wrap list items
+ html = html.replace(/(
.*<\/li>)+/g, '');
+
+ return '' + html + '
';
+ },
+
+ /**
+ * 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();
+});
diff --git a/templates/partials/settings-modal.html b/templates/partials/settings-modal.html
index 8a80c82..a3d66db 100644
--- a/templates/partials/settings-modal.html
+++ b/templates/partials/settings-modal.html
@@ -12,6 +12,7 @@
+
@@ -147,6 +148,41 @@
+
+
+
+
Update Status
+
+
+ Loading update status...
+
+
+
+
+
+
+
Update Settings
+
+
+
+ Auto-Check for Updates
+ Periodically check GitHub for new releases
+
+
+
+
+
+
+ Note: Updates are fetched from GitHub and applied via git pull.
+ Make sure you have git installed and the application is in a git repository.
+
+
+
diff --git a/utils/updater.py b/utils/updater.py
new file mode 100644
index 0000000..7791bc2
--- /dev/null
+++ b/utils/updater.py
@@ -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)
+ }