feat: Implement Git-based update functionality with nightly mode support and UI enhancements

This commit is contained in:
Will Miao
2025-07-24 18:53:37 +08:00
parent d6145e633f
commit 6f3aeb61e7
9 changed files with 467 additions and 34 deletions

View File

@@ -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"""

View File

@@ -14,7 +14,8 @@ dependencies = [
"requests",
"toml",
"natsort",
"msgpack"
"msgpack",
"GitPython"
]
[project.urls]

View File

@@ -11,3 +11,4 @@ numpy
natsort
msgpack
pyyaml
GitPython

View File

@@ -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);

View File

@@ -223,11 +223,6 @@
opacity: 1;
}
.update-badge.hidden,
.update-badge:not(.visible) {
opacity: 0;
}
/* Mobile adjustments */
@media (max-width: 768px) {
.app-title {

View File

@@ -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;

View File

@@ -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 = `
<div style="text-align: center; color: var(--lora-success);">
<i class="fas fa-check-circle" style="margin-right: 8px;"></i>
Successfully updated to ${newVersion}!
<br><br>
<small style="opacity: 0.8;">
Please restart ComfyUI to complete the update process.
</small>
</div>
`;
}
// Update current version display
this.currentVersion = newVersion;
this.updateAvailable = false;
// Refresh the modal content
setTimeout(() => {
this.updateModalContent();
this.showUpdateProgress(false);
}, 2000);
}
// Simple markdown parser for changelog items
parseMarkdown(text) {
if (!text) return '';

View File

@@ -53,7 +53,7 @@
</div>
<div class="update-toggle" id="updateToggleBtn" title="Check Updates">
<i class="fas fa-bell"></i>
<span class="update-badge hidden"></span>
<span class="update-badge"></span>
</div>
<div class="support-toggle" id="supportToggleBtn" title="Support">
<i class="fas fa-heart"></i>

View File

@@ -476,9 +476,26 @@
<span class="version-number">v0.0.0</span>
</div>
</div>
<a href="https://github.com/willmiao/ComfyUI-Lora-Manager" target="_blank" class="update-link">
<i class="fas fa-external-link-alt"></i> View on GitHub
</a>
<div class="update-actions">
<a href="https://github.com/willmiao/ComfyUI-Lora-Manager" target="_blank" class="update-link">
<i class="fas fa-external-link-alt"></i> View on GitHub
</a>
<button id="updateBtn" class="primary-btn disabled">
<i class="fas fa-download"></i>
<span id="updateBtnText">Update Now</span>
</button>
</div>
</div>
<!-- Update Progress Section -->
<div class="update-progress" id="updateProgress" style="display: none;">
<div class="progress-info">
<div class="progress-text" id="updateProgressText">Preparing update...</div>
<div class="progress-bar">
<div class="progress-fill" id="updateProgressFill"></div>
</div>
</div>
</div>
<div class="changelog-section">