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 = ` +
+
+
+ + + + + + + + Update Available + +
+
+ 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 = ` +
+
+
+ + + + + + + + Update Available +
+ +
+
+
+
+ Current + v${data.current_version} +
+
+ + + + +
+
+ Latest + v${data.latest_version} +
+
+ +
+
Release Notes
+
${releaseNotes}
+
+ + + + + + + + +
+ +
+ `; + + 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) + }