diff --git a/__init__.py b/__init__.py index 6968f70..81254b9 100644 --- a/__init__.py +++ b/__init__.py @@ -1,12 +1,13 @@ """ @author: Azornes @title: ComfyUI Model Resolver -@version: 1.0.0 +@version: 1.1.0 @description: Extension for resolving missing models and downloading from HuggingFace/CivitAI """ import asyncio import os +import re import sys import threading import time @@ -47,6 +48,88 @@ from .core.path_utils import ( # Web directory for JavaScript interface WEB_DIRECTORY = "./web" +PROJECT_VERSION_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pyproject.toml") +PROJECT_GITHUB_URL = "https://github.com/Azornes/Comfyui-Model-Resolver" +PROJECT_GITHUB_PYPROJECT_URL = ( + "https://raw.githubusercontent.com/Azornes/Comfyui-Model-Resolver/main/pyproject.toml" +) +PROJECT_VERSION_CACHE_TTL_SECONDS = 6 * 60 * 60 +_project_version_cache = {"checked_at": 0.0, "latest_version": None} +_project_version_cache_lock = threading.Lock() + + +def _extract_project_version(text: str) -> str: + """Extract the package version from a pyproject.toml document.""" + match = re.search(r"(?m)^\s*version\s*=\s*[\"']([^\"']+)[\"']\s*$", str(text or "")) + return match.group(1).strip() if match else "" + + +def _get_local_project_version() -> str: + try: + with open(PROJECT_VERSION_FILE, encoding="utf-8") as version_file: + return _extract_project_version(version_file.read()) or "unknown" + except (OSError, UnicodeError): + return "unknown" + + +def _version_sort_key(version: str) -> tuple[int, ...]: + return tuple(int(part) for part in re.findall(r"\d+", str(version or ""))) + + +def _get_project_version_info() -> dict[str, Any]: + """Return the installed version and the version currently published on GitHub.""" + current_version = _get_local_project_version() + now = time.monotonic() + + with _project_version_cache_lock: + cached_at = float(_project_version_cache.get("checked_at") or 0.0) + latest_version = _project_version_cache.get("latest_version") + cache_is_fresh = now - cached_at < PROJECT_VERSION_CACHE_TTL_SECONDS + + if not cache_is_fresh: + latest_version = None + try: + from .core.network_utils import request_source_response + + response = request_source_response( + PROJECT_GITHUB_PYPROJECT_URL, + timeout=5, + max_attempts=1, + log_name="GitHub version check", + ) + try: + if response is not None and response.status_code == 200: + latest_version = _extract_project_version(response.text) + finally: + if response is not None: + response.close() + except Exception: + latest_version = None + + with _project_version_cache_lock: + _project_version_cache.update( + { + "checked_at": now, + "latest_version": latest_version or None, + } + ) + + current_key = _version_sort_key(current_version) + latest_key = _version_sort_key(latest_version or "") + if not latest_version or not latest_key: + status = "unavailable" + elif current_key and latest_key > current_key: + status = "update_available" + else: + status = "current" + + return { + "current_version": current_version, + "latest_version": latest_version, + "status": status, + "github_url": PROJECT_GITHUB_URL, + } + MODEL_RESOLVER_DEPENDENCY_NODE_TYPE = "ModelResolverDependency" MODEL_RESOLVER_DEPENDENCY_NODE_DISPLAY_NAME = "Model Resolver Opener" MODEL_RESOLVER_DEPENDENCY_NODE_CATEGORY = "Model Resolver/Workflow" @@ -5018,6 +5101,12 @@ class ModelResolverExtension: } ) + @routes.get("/model_resolver/version") + @json_api_endpoint("version") + async def get_project_version(request): + """Return the installed version and the latest GitHub version.""" + return web.json_response(await asyncio.to_thread(_get_project_version_info)) + @routes.get("/model_resolver/subfolders/{category}") @json_api_endpoint("subfolders") async def get_subfolders(request): diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index 9ad0ba3..2dcb697 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -3321,7 +3321,7 @@ .mr-footer { display: flex; flex-direction: row; - justify-content: center; + justify-content: flex-start; align-items: center; gap: var(--mr-spacing-10); flex-wrap: wrap; @@ -3349,6 +3349,58 @@ display: none; } +.mr-footer:not(.mr-footer-is-missing) .mr-footer-star-link { + margin-right: auto; +} + +.mr-footer-version { + display: inline-flex; + align-items: center; + gap: var(--mr-spacing-7); + flex: 0 0 auto; + order: -1; + margin-left: 0; + color: var(--mr-color-gray-400); + font-size: var(--mr-font-sm); + line-height: 1.2; + white-space: nowrap; +} + +.mr-footer-version-current { + color: var(--mr-color-gray-300); +} + +.mr-footer-version-latest { + color: var(--mr-color-gray-500); + text-decoration: none; +} + +.mr-footer-version-latest:hover, +.mr-footer-version-latest:focus-visible { + color: inherit; + text-decoration: underline; +} + +.mr-footer-version-latest:focus-visible { + outline: none; +} + +.mr-footer-version-latest.mr-footer-version-update { + font-weight: 400; +} + +.mr-footer-version.mr-footer-version-up-to-date .mr-footer-version-current { + color: #72d38a; +} + +.mr-footer-version.mr-footer-version-outdated .mr-footer-version-current { + color: #f56f6f; +} + +.mr-footer-version.mr-footer-version-hidden { + display: none; +} + .mr-footer-star-link { display: flex; align-items: center; diff --git a/web/resolver/actions/queue_methods.js b/web/resolver/actions/queue_methods.js index 5ea9e2c..55cc50c 100644 --- a/web/resolver/actions/queue_methods.js +++ b/web/resolver/actions/queue_methods.js @@ -3,6 +3,8 @@ import { api } from "../../../../../scripts/api.js"; import { $el } from "../../../../../scripts/ui.js"; import { getSvgIcon } from "../../utils/icon_utils.js"; import { startSplitterDrag } from "../utils/splitter_drag.js"; +const FOOTER_VERSION_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000; + export const queueMethods = { createContent() { // Wrap the body in a two-column layout: left = items, right = queued panel @@ -2830,8 +2832,7 @@ export const queueMethods = { this.footerElement.classList.toggle('mr-footer-is-missing', this.activeTab === 'missing'); this.footerStarLink.classList.remove('mr-footer-star-hidden'); - - if (this.activeTab !== 'missing') return; + this.footerVersionElement?.classList.remove('mr-footer-version-hidden'); const footerStyle = getComputedStyle(this.footerElement); const footerWidth = (this.footerElement.clientWidth || 0) @@ -2839,11 +2840,13 @@ export const queueMethods = { - (parseFloat(footerStyle.paddingRight) || 0); if (footerWidth <= 0) return; - const actionItems = Array.from(this.footerActions.children || []).filter((element) => { - const style = getComputedStyle(element); - return style.display !== 'none' && style.visibility !== 'hidden'; - }); - if (!actionItems.length) return; + const actionsVisible = getComputedStyle(this.footerActions).display !== 'none'; + const actionItems = actionsVisible + ? Array.from(this.footerActions.children || []).filter((element) => { + const style = getComputedStyle(element); + return style.display !== 'none' && style.visibility !== 'hidden'; + }) + : []; const gap = parseFloat(footerStyle.columnGap || footerStyle.gap || '0') || 0; const actionsStyle = getComputedStyle(this.footerActions); @@ -2851,8 +2854,24 @@ export const queueMethods = { const actionsWidth = actionItems.reduce((total, element) => { return total + element.getBoundingClientRect().width; }, 0) + (Math.max(0, actionItems.length - 1) * actionGap); - const requiredWidth = this.footerStarLink.scrollWidth + actionsWidth + gap; - this.footerStarLink.classList.toggle('mr-footer-star-hidden', requiredWidth > footerWidth); + const starWidth = this.footerStarLink.scrollWidth; + const versionWidth = this.footerVersionElement?.scrollWidth || 0; + const getRequiredWidth = ({ includeStar = true, includeVersion = true } = {}) => { + const itemWidths = [ + includeStar ? starWidth : 0, + actionsWidth, + includeVersion ? versionWidth : 0 + ]; + const visibleWidths = itemWidths.filter((width) => width > 0); + return visibleWidths.reduce((total, width) => total + width, 0) + + (Math.max(0, visibleWidths.length - 1) * gap); + }; + + const shouldHideStar = getRequiredWidth() > footerWidth; + const shouldHideVersion = versionWidth > 0 + && getRequiredWidth({ includeStar: !shouldHideStar }) > footerWidth; + this.footerVersionElement?.classList.toggle('mr-footer-version-hidden', shouldHideVersion); + this.footerStarLink.classList.toggle('mr-footer-star-hidden', shouldHideStar); }, bindFooterStarLayoutObserver() { @@ -2860,6 +2879,65 @@ export const queueMethods = { this._footerStarResizeObserver = new ResizeObserver(() => this.updateFooterStarLayout()); this._footerStarResizeObserver.observe(this.footerElement); this._footerStarResizeObserver.observe(this.footerActions); + if (this.footerVersionElement) this._footerStarResizeObserver.observe(this.footerVersionElement); + }, + + async loadFooterVersion() { + if (!this.footerVersionCurrent || !this.footerVersionLatest) return; + + const now = Date.now(); + if ( + this._footerVersionLastCheckedAt + && now - this._footerVersionLastCheckedAt < FOOTER_VERSION_REFRESH_INTERVAL_MS + ) { + return; + } + this._footerVersionLastCheckedAt = now; + + try { + const data = await this.fetchJson( + '/model_resolver/version', + { silent: true }, + 'Get Model Resolver version' + ); + const formatVersion = (value) => { + const version = String(value || '').trim().replace(/^v/i, ''); + return version ? `v${version}` : ''; + }; + const currentVersion = formatVersion(data?.current_version); + const latestVersion = formatVersion(data?.latest_version); + + this.footerVersionCurrent.textContent = currentVersion || 'v—'; + this.footerVersionLatest.textContent = latestVersion + ? `(latest: ${latestVersion})` + : '(latest: unavailable)'; + const isUpToDate = data?.status === 'current'; + const isOutdated = data?.status === 'update_available'; + this.footerVersionElement?.classList.toggle('mr-footer-version-up-to-date', isUpToDate); + this.footerVersionElement?.classList.toggle('mr-footer-version-outdated', isOutdated); + this.footerVersionLatest.classList.toggle( + 'mr-footer-version-update', + isOutdated + ); + this.footerVersionLatest.title = data?.status === 'update_available' + ? 'A newer Model Resolver version is available on GitHub.' + : 'Model Resolver version status'; + this.footerVersionElement?.setAttribute( + 'aria-label', + latestVersion + ? `Model Resolver ${currentVersion}; latest GitHub version ${latestVersion}` + : `Model Resolver ${currentVersion}; latest GitHub version unavailable` + ); + this.updateFooterStarLayout(); + } catch (_) { + this.footerVersionLatest.textContent = '(latest: unavailable)'; + this.footerVersionElement?.classList.remove( + 'mr-footer-version-up-to-date', + 'mr-footer-version-outdated' + ); + this.footerVersionLatest.classList.remove('mr-footer-version-update'); + this.updateFooterStarLayout(); + } }, createFooter() { @@ -3007,7 +3085,23 @@ export const queueMethods = { }) ]); + const versionCurrent = $el("span.mr-footer-version-current", { + textContent: "v—" + }); + const versionLatest = $el("a.mr-footer-version-latest", { + href: "https://github.com/Azornes/Comfyui-Model-Resolver", + target: "_blank", + rel: "noopener noreferrer", + textContent: "(latest: checking…)" + }); + const versionElement = $el("span.mr-footer-version", { + title: "Model Resolver version" + }, [versionCurrent, versionLatest]); + this.footerStarLink = starLink; + this.footerVersionElement = versionElement; + this.footerVersionCurrent = versionCurrent; + this.footerVersionLatest = versionLatest; this.footerActions = $el("div.mr-footer-actions", {}, [ selectMenu, searchMenu, @@ -3016,7 +3110,8 @@ export const queueMethods = { ]); this.footerElement = $el("div.mr-footer", {}, [ starLink, - this.footerActions + this.footerActions, + versionElement ]); this.bindFooterStarLayoutObserver(); queueMicrotask(() => this.updateFooterStarLayout()); diff --git a/web/resolver/shell/lifecycle_graph_methods.js b/web/resolver/shell/lifecycle_graph_methods.js index d5132ff..52a2b45 100644 --- a/web/resolver/shell/lifecycle_graph_methods.js +++ b/web/resolver/shell/lifecycle_graph_methods.js @@ -18,6 +18,7 @@ export const lifecycleGraphMethods = { async showContent(workflow = null, { restoreFullscreen = true } = {}) { this.backdrop.style.display = "none"; this.element.style.display = "flex"; + void this.loadFooterVersion(); if (!this.docked) { this.scheduleModalViewportClamp(); }