diff --git a/py/routes/update_routes.py b/py/routes/update_routes.py index 3b45c0f1..0d126cb5 100644 --- a/py/routes/update_routes.py +++ b/py/routes/update_routes.py @@ -1,11 +1,13 @@ import os +import subprocess import aiohttp import logging import toml -import subprocess +import git from datetime import datetime from aiohttp import web -from typing import Dict, Any, List +from typing import Dict, List + logger = logging.getLogger(__name__) @@ -17,6 +19,7 @@ class UpdateRoutes: """Register update check routes""" app.router.add_get('/api/check-updates', UpdateRoutes.check_updates) app.router.add_get('/api/version-info', UpdateRoutes.get_version_info) + app.router.add_post('/api/perform-update', UpdateRoutes.perform_update) @staticmethod async def check_updates(request): @@ -25,6 +28,8 @@ class UpdateRoutes: Returns update status and version information """ try: + nightly = request.query.get('nightly', 'false').lower() == 'true' + # Read local version from pyproject.toml local_version = UpdateRoutes._get_local_version() @@ -32,13 +37,21 @@ class UpdateRoutes: git_info = UpdateRoutes._get_git_info() # Fetch remote version from GitHub - remote_version, changelog = await UpdateRoutes._get_remote_version() + if nightly: + remote_version, changelog = await UpdateRoutes._get_nightly_version() + else: + remote_version, changelog = await UpdateRoutes._get_remote_version() # Compare versions - update_available = UpdateRoutes._compare_versions( - local_version.replace('v', ''), - remote_version.replace('v', '') - ) + if nightly: + # For nightly, compare commit hashes + update_available = UpdateRoutes._compare_nightly_versions(git_info, remote_version) + else: + # For stable, compare semantic versions + update_available = UpdateRoutes._compare_versions( + local_version.replace('v', ''), + remote_version.replace('v', '') + ) return web.json_response({ 'success': True, @@ -46,7 +59,8 @@ class UpdateRoutes: 'latest_version': remote_version, 'update_available': update_available, 'changelog': changelog, - 'git_info': git_info + 'git_info': git_info, + 'nightly': nightly }) except Exception as e: @@ -55,7 +69,7 @@ class UpdateRoutes: 'success': False, 'error': str(e) }) - + @staticmethod async def get_version_info(request): """ @@ -84,6 +98,168 @@ class UpdateRoutes: 'error': str(e) }) + @staticmethod + async def perform_update(request): + """ + Perform Git-based update to latest release tag or main branch + """ + try: + # Parse request body + body = await request.json() if request.has_body else {} + nightly = body.get('nightly', False) + + # Get current plugin directory + current_dir = os.path.dirname(os.path.abspath(__file__)) + plugin_root = os.path.dirname(os.path.dirname(current_dir)) + + # Backup settings.json if it exists + settings_path = os.path.join(plugin_root, 'settings.json') + settings_backup = None + if os.path.exists(settings_path): + with open(settings_path, 'r', encoding='utf-8') as f: + settings_backup = f.read() + logger.info("Backed up settings.json") + + # Perform Git update + success, new_version = await UpdateRoutes._perform_git_update(plugin_root, nightly) + + # Restore settings.json if we backed it up + if settings_backup and success: + with open(settings_path, 'w', encoding='utf-8') as f: + f.write(settings_backup) + logger.info("Restored settings.json") + + if success: + return web.json_response({ + 'success': True, + 'message': f'Successfully updated to {new_version}', + 'new_version': new_version + }) + else: + return web.json_response({ + 'success': False, + 'error': 'Failed to complete Git update' + }) + + except Exception as e: + logger.error(f"Failed to perform update: {e}", exc_info=True) + return web.json_response({ + 'success': False, + 'error': str(e) + }) + + @staticmethod + async def _get_nightly_version() -> tuple[str, List[str]]: + """ + Fetch latest commit from main branch + """ + repo_owner = "willmiao" + repo_name = "ComfyUI-Lora-Manager" + + # Use GitHub API to fetch the latest commit from main branch + github_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/commits/main" + + try: + async with aiohttp.ClientSession() as session: + async with session.get(github_url, headers={'Accept': 'application/vnd.github+json'}) as response: + if response.status != 200: + logger.warning(f"Failed to fetch GitHub commit: {response.status}") + return "main", [] + + data = await response.json() + commit_sha = data.get('sha', '')[:7] # Short hash + commit_message = data.get('commit', {}).get('message', '') + + # Format as "main-{short_hash}" + version = f"main-{commit_sha}" + + # Use commit message as changelog + changelog = [commit_message] if commit_message else [] + + return version, changelog + + except Exception as e: + logger.error(f"Error fetching nightly version: {e}", exc_info=True) + return "main", [] + + @staticmethod + def _compare_nightly_versions(local_git_info: Dict[str, str], remote_version: str) -> bool: + """ + Compare local commit hash with remote main branch + """ + try: + local_hash = local_git_info.get('short_hash', 'unknown') + if local_hash == 'unknown': + return True # Assume update available if we can't get local hash + + # Extract remote hash from version string (format: "main-{hash}") + if '-' in remote_version: + remote_hash = remote_version.split('-')[-1] + return local_hash != remote_hash + + return True # Default to update available + + except Exception as e: + logger.error(f"Error comparing nightly versions: {e}") + return False + + @staticmethod + async def _perform_git_update(plugin_root: str, nightly: bool = False) -> tuple[bool, str]: + """ + Perform Git-based update using GitPython + + Args: + plugin_root: Path to the plugin root directory + nightly: Whether to update to main branch or latest release + + Returns: + tuple: (success, new_version) + """ + try: + # Open the Git repository + repo = git.Repo(plugin_root) + + # Fetch latest changes + origin = repo.remotes.origin + origin.fetch() + + if nightly: + # Switch to main branch and pull latest + main_branch = 'main' + if main_branch not in [branch.name for branch in repo.branches]: + # Create local main branch if it doesn't exist + repo.create_head(main_branch, origin.refs.main) + + repo.heads[main_branch].checkout() + origin.pull(main_branch) + + # Get new commit hash + new_version = f"main-{repo.head.commit.hexsha[:7]}" + + else: + # Get latest release tag + tags = sorted(repo.tags, key=lambda t: t.commit.committed_datetime, reverse=True) + if not tags: + logger.error("No tags found in repository") + return False, "" + + latest_tag = tags[0] + + # Checkout to latest tag + repo.git.checkout(latest_tag.name) + + new_version = latest_tag.name + + logger.info(f"Successfully updated to {new_version}") + return True, new_version + + except git.exc.GitError as e: + logger.error(f"Git error during update: {e}") + return False, "" + except Exception as e: + logger.error(f"Error during Git update: {e}") + return False, "" + @staticmethod def _get_local_version() -> str: """Get local plugin version from pyproject.toml""" diff --git a/pyproject.toml b/pyproject.toml index 2af0a264..7be9dcfe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,8 @@ dependencies = [ "requests", "toml", "natsort", - "msgpack" + "msgpack", + "GitPython" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index db115810..a3423de0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ numpy natsort msgpack pyyaml +GitPython diff --git a/static/css/base.css b/static/css/base.css index 1f983dae..99aedbb7 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -50,8 +50,8 @@ html, body { --lora-border: oklch(90% 0.02 256 / 0.15); --lora-text: oklch(95% 0.02 256); --lora-error: oklch(75% 0.32 29); - --lora-warning: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); /* Modified to be used with oklch() */ - --lora-success: oklch(var(--lora-success-l) var(--lora-success-c) var(--lora-success-h)); /* New green success color */ + --lora-warning: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); + --lora-success: oklch(var(--lora-success-l) var(--lora-success-c) var(--lora-success-h)); /* Spacing Scale */ --space-1: calc(8px * 1); diff --git a/static/css/components/header.css b/static/css/components/header.css index 13918428..0eb2237a 100644 --- a/static/css/components/header.css +++ b/static/css/components/header.css @@ -223,11 +223,6 @@ opacity: 1; } -.update-badge.hidden, -.update-badge:not(.visible) { - opacity: 0; -} - /* Mobile adjustments */ @media (max-width: 768px) { .app-title { diff --git a/static/css/components/modal.css b/static/css/components/modal.css index 500afa08..031ed917 100644 --- a/static/css/components/modal.css +++ b/static/css/components/modal.css @@ -172,6 +172,91 @@ body.modal-open { opacity: 1; } +/* Update Modal specific styles */ +.update-actions { + display: flex; + flex-direction: column; + gap: var(--space-2); + align-items: stretch; + flex-wrap: nowrap; +} + +.update-link { + color: var(--lora-accent); + text-decoration: none; + display: flex; + align-items: center; + gap: 8px; + font-size: 0.95em; +} + +.update-link:hover { + text-decoration: underline; +} + +/* Update progress styles */ +.update-progress { + background: rgba(0, 0, 0, 0.03); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + padding: var(--space-2); + margin: var(--space-2) 0; +} + +[data-theme="dark"] .update-progress { + background: rgba(255, 255, 255, 0.03); +} + +.progress-info { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.progress-text { + font-size: 0.9em; + color: var(--text-color); + opacity: 0.8; +} + +.progress-bar { + width: 100%; + height: 8px; + background-color: rgba(0, 0, 0, 0.1); + border-radius: 4px; + overflow: hidden; +} + +[data-theme="dark"] .progress-bar { + background-color: rgba(255, 255, 255, 0.1); +} + +.progress-fill { + height: 100%; + background-color: var(--lora-accent); + width: 0%; + transition: width 0.3s ease; + border-radius: 4px; +} + +/* Update button states */ +#updateBtn { + min-width: 120px; +} + +#updateBtn.updating { + background-color: var(--lora-warning); + cursor: not-allowed; +} + +#updateBtn.success { + background-color: var(--lora-success); +} + +#updateBtn.error { + background-color: var(--lora-error); +} + /* Settings styles */ .settings-toggle { width: 36px; diff --git a/static/js/managers/UpdateService.js b/static/js/managers/UpdateService.js index 52a62603..dc95f98d 100644 --- a/static/js/managers/UpdateService.js +++ b/static/js/managers/UpdateService.js @@ -3,7 +3,7 @@ import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; export class UpdateService { constructor() { - this.updateCheckInterval = 24 * 60 * 60 * 1000; // 24 hours + this.updateCheckInterval = 60 * 60 * 1000; // 1 hour this.currentVersion = "v0.0.0"; // Initialize with default values this.latestVersion = "v0.0.0"; // Initialize with default values this.updateInfo = null; @@ -13,8 +13,10 @@ export class UpdateService { branch: "unknown", commit_date: "unknown" }; - this.updateNotificationsEnabled = getStorageItem('show_update_notifications'); + this.updateNotificationsEnabled = getStorageItem('show_update_notifications', true); this.lastCheckTime = parseInt(getStorageItem('last_update_check') || '0'); + this.isUpdating = false; + this.nightlyMode = getStorageItem('nightly_updates', false); } initialize() { @@ -28,23 +30,44 @@ export class UpdateService { this.updateBadgeVisibility(); }); } + + const updateBtn = document.getElementById('updateBtn'); + if (updateBtn) { + updateBtn.addEventListener('click', () => this.performUpdate()); + } + + // Register event listener for nightly update toggle + const nightlyCheckbox = document.getElementById('nightlyUpdateToggle'); + if (nightlyCheckbox) { + nightlyCheckbox.checked = this.nightlyMode; + nightlyCheckbox.addEventListener('change', (e) => { + this.nightlyMode = e.target.checked; + setStorageItem('nightly_updates', e.target.checked); + this.updateNightlyWarning(); + this.updateModalContent(); + // Re-check for updates when switching channels + this.manualCheckForUpdates(); + }); + this.updateNightlyWarning(); + } // Perform update check if needed this.checkForUpdates().then(() => { // Ensure badges are updated after checking this.updateBadgeVisibility(); }); - - // Set up event listener for update button - // const updateToggle = document.getElementById('updateToggleBtn'); - // if (updateToggle) { - // updateToggle.addEventListener('click', () => this.toggleUpdateModal()); - // } // Immediately update modal content with current values (even if from default) this.updateModalContent(); } + updateNightlyWarning() { + const warning = document.getElementById('nightlyWarning'); + if (warning) { + warning.style.display = this.nightlyMode ? 'flex' : 'none'; + } + } + async checkForUpdates() { // Check if we should perform an update check const now = Date.now(); @@ -59,8 +82,8 @@ export class UpdateService { } try { - // Call backend API to check for updates - const response = await fetch('/api/check-updates'); + // Call backend API to check for updates with nightly flag + const response = await fetch(`/api/check-updates?nightly=${this.nightlyMode}`); const data = await response.json(); if (data.success) { @@ -137,8 +160,8 @@ export class UpdateService { const shouldShow = this.updateNotificationsEnabled && this.updateAvailable; if (updateBadge) { - updateBadge.classList.toggle('hidden', !shouldShow); - console.log("Update badge visibility:", !shouldShow ? "hidden" : "visible"); + updateBadge.classList.toggle('visible', shouldShow); + console.log("Update badge visibility:", shouldShow ? "visible" : "hidden"); } } @@ -157,7 +180,17 @@ export class UpdateService { const newVersionEl = modal.querySelector('.new-version .version-number'); if (currentVersionEl) currentVersionEl.textContent = this.currentVersion; - if (newVersionEl) newVersionEl.textContent = this.latestVersion; + + if (newVersionEl) { + newVersionEl.textContent = this.latestVersion; + } + + // Update update button state + const updateBtn = modal.querySelector('#updateBtn'); + if (updateBtn) { + updateBtn.classList.toggle('disabled', !this.updateAvailable || this.isUpdating); + updateBtn.disabled = !this.updateAvailable || this.isUpdating; + } // Update git info const gitInfoEl = modal.querySelector('.git-info'); @@ -218,6 +251,131 @@ export class UpdateService { } } + async performUpdate() { + if (!this.updateAvailable || this.isUpdating) { + return; + } + + try { + this.isUpdating = true; + this.updateUpdateUI('updating', 'Updating...'); + this.showUpdateProgress(true); + + // Update progress + this.updateProgress(10, 'Preparing update...'); + + const response = await fetch('/api/perform-update', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + nightly: this.nightlyMode + }) + }); + + this.updateProgress(50, 'Installing update...'); + + const data = await response.json(); + + if (data.success) { + this.updateProgress(100, 'Update completed successfully!'); + this.updateUpdateUI('success', 'Updated!'); + + // Show success message and suggest restart + setTimeout(() => { + this.showUpdateCompleteMessage(data.new_version); + }, 1000); + + } else { + throw new Error(data.error || 'Update failed'); + } + + } catch (error) { + console.error('Update failed:', error); + this.updateUpdateUI('error', 'Update Failed'); + this.updateProgress(0, `Update failed: ${error.message}`); + + // Hide progress after error + setTimeout(() => { + this.showUpdateProgress(false); + }, 3000); + } finally { + this.isUpdating = false; + } + } + + updateUpdateUI(state, text) { + const updateBtn = document.getElementById('updateBtn'); + const updateBtnText = document.getElementById('updateBtnText'); + + if (updateBtn && updateBtnText) { + // Remove existing state classes + updateBtn.classList.remove('updating', 'success', 'error', 'disabled'); + + // Add new state class + if (state !== 'normal') { + updateBtn.classList.add(state); + } + + // Update button text + updateBtnText.textContent = text; + + // Update disabled state + updateBtn.disabled = (state === 'updating' || state === 'disabled'); + } + } + + showUpdateProgress(show) { + const progressContainer = document.getElementById('updateProgress'); + if (progressContainer) { + progressContainer.style.display = show ? 'block' : 'none'; + } + } + + updateProgress(percentage, text) { + const progressFill = document.getElementById('updateProgressFill'); + const progressText = document.getElementById('updateProgressText'); + + if (progressFill) { + progressFill.style.width = `${percentage}%`; + } + + if (progressText) { + progressText.textContent = text; + } + } + + showUpdateCompleteMessage(newVersion) { + const modal = document.getElementById('updateModal'); + if (!modal) return; + + // Update the modal content to show completion + const progressText = document.getElementById('updateProgressText'); + if (progressText) { + progressText.innerHTML = ` +