diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c610fc8..fe1d332 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: run: | node tests/js/test_ai_pipeline_preview.mjs node tests/js/test_draft_guard.mjs + node tests/js/test_update_check.mjs security-gate: name: Security Gate diff --git a/CHANGELOG.md b/CHANGELOG.md index 395a03d..87f0a0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,14 @@ The format is inspired by Keep a Changelog and SemVer. ## [Unreleased] +## [0.4.1] - 2026-06-18 + ### Added +- **Sidebar update notice.** The Kforge Labs sidebar now checks the installed + pack version against GitHub's latest release and shows a discreet + footer-slot "Update Available" notice only when a newer release exists. The + notice links to the release notes and stays hidden when the check fails or + the install is already current, so offline ComfyUI sessions remain quiet. - **Easy Image Batch — review hardening.** `keyframe_batch` is kept as a **deprecated alias** of `keyframes_insert` (pre-rename workflows keep loading; an explicit `keyframes_insert` wins if both are wired; removal deferred to a diff --git a/docs/designs/README.md b/docs/designs/README.md index 2f28725..fd8a045 100644 --- a/docs/designs/README.md +++ b/docs/designs/README.md @@ -7,6 +7,7 @@ before they land in the runtime sidebar. |---|---| | [`sidebar-icon-proposals.html`](sidebar-icon-proposals.html) | Side-by-side icon direction mockup for the Kforge Labs sidebar action rows | | [`sidebar-icon-proposals.png`](sidebar-icon-proposals.png) | Rendered preview of the current icon proposal state | +| [`update-banner.html`](update-banner.html) | Footer banner layout proposal for a discreet "Update Available" release-notes link | | [`snapshot-dialogs-redesign.md`](snapshot-dialogs-redesign.md) | Scope sketch + Mermaid flow for consolidating Settings cog into Save / Load dialogs (+ autosave-newest fix) | | [`snapshot-dialogs.html`](snapshot-dialogs.html) | Side-by-side mockups: current vs. proposed Snapshot toolbar, Save dialog, Load dialog, and autosave YES/NO modal | diff --git a/docs/designs/update-banner.html b/docs/designs/update-banner.html new file mode 100644 index 0000000..3609399 --- /dev/null +++ b/docs/designs/update-banner.html @@ -0,0 +1,276 @@ + + + + + + Update Available Banner - Sidebar Mockup + + + +
+

Sidebar Update Available Banner

+

Chosen quiet footer treatment for a future release-check feature. It points users to release notes without interrupting ComfyUI work or stealing space from the main sidebar lists. The notice uses the normal user-facing empty footer slot; the dev build line is omitted because normal users will not see it.

+ +
+

Footer Slot Text

+

Discreet release notice at the bottom of the sidebar, using the footer slot instead of adding another row.

+ +

Lowest visual weight. This is the closest match if we want the notice to live where the hidden dev footer would otherwise be.

+
+
+ + diff --git a/koolook_routes.py b/koolook_routes.py index c1b1b20..dd5f54f 100644 --- a/koolook_routes.py +++ b/koolook_routes.py @@ -90,6 +90,26 @@ _DIRNAME_RE = re.compile(r"^[A-Za-z0-9 _.()\-]+$") # filter; can be removed in a future major if we choose to migrate. _HIDDEN_LIST_PREFIXES = ("_autosave_",) _WEB_DIR = Path(__file__).resolve().parent / "web" +_REPO_ROOT = Path(__file__).resolve().parent +_GITHUB_RELEASES_URL = "https://github.com/malkuthro/ComfyUI-Koolook/releases" +_GITHUB_LATEST_RELEASE_API_URL = ( + "https://api.github.com/repos/malkuthro/ComfyUI-Koolook/releases/latest" +) + + +def _read_pack_version() -> str: + """Read the installed Koolook pack version from ``pyproject.toml``. + + Kept local and dependency-free so the ComfyUI runtime does not need package + metadata installed. Returns ``"unknown"`` if the file is unavailable or + malformed; the frontend treats that as "do not show update notice". + """ + try: + text = (_REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") + except OSError: + return "unknown" + match = re.search(r'(?m)^\s*version\s*=\s*["\']([^"\']+)["\']', text) + return match.group(1).strip() if match else "unknown" def _latest_autosave_mtime(autosave_dir: Path, named_mtime: float) -> float | None: @@ -564,6 +584,16 @@ def register_routes(routes, setup_registry_factory=None, setup_runner_factory=No async def setup_runner_simulator_js(_request): return _web_asset_response("setup_runner_simulator.js", "application/javascript") + @routes.get("/koolook/api/version") + async def get_koolook_version(_request): + return web.json_response( + { + "version": _read_pack_version(), + "releasesUrl": _GITHUB_RELEASES_URL, + "latestReleaseApiUrl": _GITHUB_LATEST_RELEASE_API_URL, + } + ) + @routes.get("/koolook/api/setups") async def list_published_setups(_request): registry: PublishedSetupRegistry = setup_registry_factory() diff --git a/pyproject.toml b/pyproject.toml index 94787cc..b6d1366 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "koolook" description = "This repository provides custom nodes for ComfyUI tailored for VFX and AI image/video generation workflows" -version = "0.3.7" +version = "0.4.1" license = {file = "LICENSE"} # classifiers = [ # # For OS-independent nodes (works on all operating systems) diff --git a/tests/js/test_update_check.mjs b/tests/js/test_update_check.mjs new file mode 100644 index 0000000..1f9f50e --- /dev/null +++ b/tests/js/test_update_check.mjs @@ -0,0 +1,89 @@ +// Behavior tests for the sidebar update-available check. + +import assert from "node:assert/strict"; +import path from "node:path"; + +const repoRoot = path.resolve(import.meta.dirname, "../.."); +const updateCheck = await import( + `file:///${path.join(repoRoot, "web", "sidebar", "update_check.js").replaceAll("\\", "/")}` +); + +function jsonResponse(body, ok = true) { + return { + ok, + async json() { + return body; + }, + }; +} + +assert.equal(updateCheck.compareSemver("0.4.1", "0.4.0"), 1); +assert.equal(updateCheck.compareSemver("v0.4.0", "0.4.0"), 0); +assert.equal(updateCheck.compareSemver("0.3.9", "0.4.0"), -1); +assert.equal(updateCheck.compareSemver("0.4.0", "0.4.0-rc.1"), 1); +assert.equal(updateCheck.compareSemver("not-a-version", "0.4.0"), 0); + +{ + const calls = []; + const update = await updateCheck.checkForUpdate(async (url) => { + calls.push(String(url)); + if (calls.length === 1) { + assert.ok(calls[0].startsWith("/koolook/api/version?_=")); + return jsonResponse({ + version: "0.4.0", + latestReleaseApiUrl: "https://api.example.test/latest", + }); + } + assert.equal(url, "https://api.example.test/latest"); + return jsonResponse({ + tag_name: "v0.4.1", + html_url: "https://github.com/malkuthro/ComfyUI-Koolook/releases/tag/v0.4.1", + }); + }); + assert.deepEqual(update, { + current: "0.4.0", + latest: "0.4.1", + url: "https://github.com/malkuthro/ComfyUI-Koolook/releases/tag/v0.4.1", + }); +} + +{ + let calls = 0; + const update = await updateCheck.checkForUpdate(async () => { + calls += 1; + if (calls === 1) return jsonResponse({ version: "0.4.0" }); + return jsonResponse({ + tag_name: "v0.4.1", + html_url: "https://github.example.test/releases/v0.4.1", + }); + }); + assert.deepEqual(update, { + current: "0.4.0", + latest: "0.4.1", + url: "https://github.com/malkuthro/ComfyUI-Koolook/releases/tag/v0.4.1", + }); +} + +{ + const update = await updateCheck.checkForUpdate(async () => jsonResponse({ version: "0.4.0" })); + assert.equal(update, null); +} + +{ + let calls = 0; + const update = await updateCheck.checkForUpdate(async () => { + calls += 1; + if (calls === 1) return jsonResponse({ version: "0.4.0" }); + return jsonResponse({ tag_name: "v0.4.0", html_url: "https://example.test/current" }); + }); + assert.equal(update, null); +} + +{ + const update = await updateCheck.checkForUpdate(async () => { + throw new Error("offline"); + }); + assert.equal(update, null); +} + +console.log("test_update_check.mjs: all scenarios passed"); diff --git a/tests/server/test_published_setup_api.py b/tests/server/test_published_setup_api.py index 6ea7f7c..315356e 100644 --- a/tests/server/test_published_setup_api.py +++ b/tests/server/test_published_setup_api.py @@ -53,6 +53,21 @@ def test_setup_runner_simulator_routes_serve_html_and_js() -> None: asyncio.run(exercise()) +def test_version_route_exposes_installed_version_and_release_urls() -> None: + async def exercise() -> None: + app = _app_with_registry(PublishedSetupRegistry(StaticSetupStorage([]))) + + response = await _handle(app, "GET", "/koolook/api/version") + + assert response.status == 200 + body = _json_body(response) + assert body["version"] == "0.4.1" + assert body["releasesUrl"] == "https://github.com/malkuthro/ComfyUI-Koolook/releases" + assert body["latestReleaseApiUrl"].endswith("/malkuthro/ComfyUI-Koolook/releases/latest") + + asyncio.run(exercise()) + + def test_catalog_list_omits_invalid_setups() -> None: async def exercise() -> None: invalid = deepcopy(_valid_setup()) diff --git a/tests/sidebar/test_update_footer.py b/tests/sidebar/test_update_footer.py new file mode 100644 index 0000000..52fe37a --- /dev/null +++ b/tests/sidebar/test_update_footer.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +TREE_JS = REPO_ROOT / "web" / "sidebar" / "tree.js" + + +def test_compare_panels_disable_update_footer() -> None: + text = TREE_JS.read_text(encoding="utf-8") + + assert "showUpdateFooter: false" in text + assert "if (showUpdateFooter) {" in text diff --git a/web/sidebar/constants.js b/web/sidebar/constants.js index 6d10fe7..627d7f7 100644 --- a/web/sidebar/constants.js +++ b/web/sidebar/constants.js @@ -320,7 +320,11 @@ export function ensureStyle() { .koolook-preview-widget-name { text-align: left; text-overflow: ellipsis; white-space: nowrap; } .koolook-preview-widget-value { text-align: right; font-size: 11px; opacity: 0.85; font-family: monospace; text-overflow: ellipsis; white-space: nowrap; } .koolook-preview-desc { margin: 10px 9px 0; padding: 7px 9px; background: rgba(0,0,0,0.18); border: 1px solid rgba(255,255,255,0.05); border-radius: 6px; font-style: italic; font-weight: 500; font-size: 11px; line-height: 1.45; word-break: break-word; opacity: 0.86; max-height: 120px; overflow: hidden; } -.koolook-build-tag { flex-shrink: 0; padding: 6px 10px 14px; font-size: 10px; opacity: 0.5; text-align: center; letter-spacing: 0.04em; font-family: monospace; color: var(--input-text, inherit); line-height: 1.45; } +.koolook-update-footer { flex-shrink: 0; display: none; justify-content: center; gap: 6px; padding: 4px 10px 12px; color: var(--input-text, inherit); opacity: 0.62; font-size: 11px; line-height: 1.45; text-align: center; } +.koolook-update-footer-visible { display: flex; } +.koolook-update-footer a { color: #b9d8ff; font-weight: 700; text-decoration: none; opacity: 1; } +.koolook-update-footer a:hover { color: #ffffff; } +.koolook-build-tag { flex-shrink: 0; padding: 0 10px 12px; font-size: 10px; opacity: 0.5; text-align: center; letter-spacing: 0.04em; font-family: monospace; color: var(--input-text, inherit); line-height: 1.45; } .koolook-build-sha { font-size: 13px; letter-spacing: 0.06em; } .koolook-build-scope { display: block; font-size: 12px; margin-top: 3px; letter-spacing: 0.02em; font-family: var(--font-family, sans-serif); font-style: italic; } /* Folder-browse picker (issue #137, mockup section 6). Navigate-into diff --git a/web/sidebar/tree.js b/web/sidebar/tree.js index fd454ea..62b496b 100644 --- a/web/sidebar/tree.js +++ b/web/sidebar/tree.js @@ -74,6 +74,7 @@ import { clearWorkflowsRenderSource, } from "./workflows_store.js"; import { diffPicks, diffWorkflows, getWorkflowEntryFromStore } from "./snapshot_diff.js"; +import { checkForUpdate, renderUpdateFooter } from "./update_check.js"; import { serializeFullCanvas, serializeSelection, @@ -2544,6 +2545,7 @@ export function renderSidebar(container) { signal, readOnly: !liveIsActive, pullIn: liveIsActive ? null : pullIn, + showUpdateFooter: false, }); renderPanel(rightCol.host, { compare: true, @@ -2553,6 +2555,7 @@ export function renderSidebar(container) { compareName: snapName, readOnly: true, pullIn: liveIsActive ? pullIn : null, + showUpdateFooter: false, }); // Diff/tint/filter follow the SOURCE (the read-only side you copy FROM): // "new" = in the source, not the target — the copy candidates — so the @@ -3105,7 +3108,7 @@ function applyCompareTint(panelEl, targetPicks, targetStore, sourcePicks, source } export function renderPanel(container, options = {}) { - const { compare = false, snapshot = null, onToggleCompare = null, signal = null, compareName = "", readOnly = compare, pullIn = null } = options; + const { compare = false, snapshot = null, onToggleCompare = null, signal = null, compareName = "", readOnly = compare, pullIn = null, showUpdateFooter = true } = options; // Compare mode (#181): when `compare` is set, every tree (re-)render in // this panel reads from the loaded `snapshot` via the read-only store // overrides, and the live-change listeners at the bottom are skipped so @@ -3828,6 +3831,13 @@ export function renderPanel(container, options = {}) { withSource(() => renderTree({ treeEl: tree, query: "" })); + const updateFooter = document.createElement("div"); + updateFooter.className = "koolook-update-footer"; + container.appendChild(updateFooter); + if (showUpdateFooter) { + checkForUpdate().then((update) => renderUpdateFooter(updateFooter, update)); + } + // ---- Build tag (dev-sync verification) ---- // `scripts/sync_to_dev.py` writes `web/_dev_build.json` post-sync so the // maintainer can eyeball whether the live ComfyUI is running the latest diff --git a/web/sidebar/update_check.js b/web/sidebar/update_check.js new file mode 100644 index 0000000..d54a379 --- /dev/null +++ b/web/sidebar/update_check.js @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: GPL-3.0-only + +import { noStoreUrl } from "./constants.js"; + +const LOCAL_VERSION_ROUTE = "/koolook/api/version"; +const FALLBACK_LATEST_RELEASE_API = + "https://api.github.com/repos/malkuthro/ComfyUI-Koolook/releases/latest"; +const RELEASES_URL = "https://github.com/malkuthro/ComfyUI-Koolook/releases"; + +function releaseNotesUrl(version, rawUrl) { + const expected = `${RELEASES_URL}/tag/v${version}`; + if (typeof rawUrl !== "string") return expected; + try { + const parsed = new URL(rawUrl); + if ( + parsed.protocol === "https:" && + parsed.hostname === "github.com" && + parsed.pathname === `/malkuthro/ComfyUI-Koolook/releases/tag/v${version}` + ) { + return parsed.href; + } + } catch (_err) { + // Fall through to the canonical release URL. + } + return expected; +} + +function parseVersion(value) { + if (typeof value !== "string") return null; + const match = value.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/); + if (!match) return null; + return { + parts: match.slice(1, 4).map((part) => Number(part)), + prerelease: match[4] || "", + }; +} + +export function parseSemver(value) { + const parsed = parseVersion(value); + return parsed ? parsed.parts : null; +} + +export function compareSemver(a, b) { + const av = parseVersion(a); + const bv = parseVersion(b); + if (!av || !bv) return 0; + for (let i = 0; i < 3; i += 1) { + if (av.parts[i] > bv.parts[i]) return 1; + if (av.parts[i] < bv.parts[i]) return -1; + } + if (av.prerelease && !bv.prerelease) return -1; + if (!av.prerelease && bv.prerelease) return 1; + return 0; +} + +function normalizeLatestRelease(payload) { + if (!payload || typeof payload !== "object") return null; + const version = typeof payload.tag_name === "string" + ? payload.tag_name.replace(/^v/i, "") + : ""; + if (!parseSemver(version)) return null; + return { + version, + url: releaseNotesUrl(version, payload.html_url), + }; +} + +export async function checkForUpdate(fetchImpl = fetch) { + try { + const localResponse = await fetchImpl(noStoreUrl(LOCAL_VERSION_ROUTE), { + cache: "no-store", + }); + if (!localResponse || !localResponse.ok) return null; + const local = await localResponse.json(); + const current = typeof local.version === "string" ? local.version : ""; + if (!parseSemver(current)) return null; + + const latestUrl = typeof local.latestReleaseApiUrl === "string" && local.latestReleaseApiUrl + ? local.latestReleaseApiUrl + : FALLBACK_LATEST_RELEASE_API; + const latestResponse = await fetchImpl(latestUrl, { cache: "no-store" }); + if (!latestResponse || !latestResponse.ok) return null; + const latest = normalizeLatestRelease(await latestResponse.json()); + if (!latest || compareSemver(latest.version, current) <= 0) return null; + + return { + current, + latest: latest.version, + url: latest.url, + }; + } catch (_err) { + return null; + } +} + +export function renderUpdateFooter(footerEl, update) { + footerEl.textContent = ""; + footerEl.classList.remove("koolook-update-footer-visible"); + if (!update) return; + + const label = document.createElement("span"); + label.textContent = "Update Available"; + const link = document.createElement("a"); + link.href = update.url; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + link.textContent = "Release notes"; + link.title = `Kforge Labs ${update.latest} release notes`; + footerEl.append(label, link); + footerEl.classList.add("koolook-update-footer-visible"); +}