Release v0.4.1 (#250)

* Release v0.4.0: update footer notice

* Release v0.4.1
This commit is contained in:
malkuthro
2026-06-19 01:38:40 +02:00
committed by GitHub
parent ae7abba73b
commit bda4df576a
12 changed files with 561 additions and 3 deletions
+1
View File
@@ -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
+7
View File
@@ -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
+1
View File
@@ -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 |
+276
View File
@@ -0,0 +1,276 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Update Available Banner - Sidebar Mockup</title>
<style>
:root {
color-scheme: dark;
--bg: #111316;
--panel: #1b1d22;
--panel-soft: #20242a;
--line: rgba(255,255,255,0.1);
--text: #e6e8ec;
--muted: #9299a4;
--blue: #6db4ff;
--blue-soft: rgba(109,180,255,0.12);
--green: #79c17d;
--input: rgba(0,0,0,0.22);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font: 13px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
width: min(1040px, calc(100vw - 32px));
margin: 28px auto 40px;
}
h1 {
margin: 0 0 8px;
font-size: 24px;
font-weight: 750;
letter-spacing: 0;
}
.intro {
max-width: 720px;
margin: 0 0 22px;
color: var(--muted);
}
.proposal {
width: min(520px, 100%);
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255,255,255,0.025);
padding: 14px;
}
.proposal h2 {
margin: 0 0 5px;
font-size: 15px;
letter-spacing: 0;
}
.proposal-copy {
margin: 0 0 14px;
color: var(--muted);
font-size: 12px;
}
.sidebar {
width: 296px;
height: 620px;
margin: 0 auto;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.13);
border-radius: 8px;
background: var(--panel);
box-shadow: 0 18px 44px rgba(0,0,0,0.32);
}
.sidebar-inner {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
padding-top: 10px;
}
.actions-row {
display: flex;
align-items: center;
gap: 3px;
padding: 2px 6px;
flex-shrink: 0;
}
.label {
flex: 1;
min-width: 0;
color: rgba(230,232,236,0.58);
font-size: 10px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.status {
flex: 1;
display: flex;
align-items: center;
min-width: 0;
font-size: 11px;
color: rgba(230,232,236,0.86);
}
.dot {
width: 7px;
height: 7px;
margin-right: 7px;
border-radius: 999px;
background: var(--green);
box-shadow: 0 0 0 1px rgba(0,0,0,0.35);
flex-shrink: 0;
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.state {
margin-left: 4px;
color: rgba(230,232,236,0.55);
font-size: 10px;
flex-shrink: 0;
}
.btn {
width: 28px;
height: 17px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line);
border-radius: 3px;
background: var(--input);
color: rgba(230,232,236,0.8);
font: 700 11px/1 system-ui, sans-serif;
}
.btn.green {
background: rgba(120,165,100,0.25);
border-color: rgba(120,165,100,0.5);
}
.divider {
margin: 8px 8px;
border-top: 1px solid rgba(255,255,255,0.08);
flex-shrink: 0;
}
.search {
margin: 6px;
padding: 5px 8px 5px 26px;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--input);
color: rgba(230,232,236,0.55);
font-size: 12px;
flex-shrink: 0;
}
.tree {
flex: 1;
min-height: 0;
overflow: hidden;
padding: 0 4px 8px;
}
.tree-row {
display: flex;
align-items: center;
gap: 6px;
min-height: 25px;
padding: 3px 6px;
border-radius: 3px;
color: rgba(230,232,236,0.88);
white-space: nowrap;
}
.tree-row.dim { color: rgba(230,232,236,0.58); }
.tree-row.indent { padding-left: 20px; }
.chev { width: 10px; color: rgba(230,232,236,0.62); text-align: center; }
.count {
margin-left: auto;
padding: 1px 7px;
border-radius: 8px;
background: rgba(255,255,255,0.08);
color: rgba(230,232,236,0.72);
font-size: 11px;
}
.inline-update {
display: flex;
justify-content: center;
gap: 6px;
padding: 4px 10px 12px;
color: rgba(230,232,236,0.62);
font-size: 11px;
flex-shrink: 0;
}
.inline-update a {
color: #b9d8ff;
font-weight: 700;
text-decoration: none;
}
.notes {
margin: 12px 2px 0;
color: var(--muted);
font-size: 12px;
}
@media (max-width: 760px) {
.sidebar { width: min(296px, 100%); }
}
</style>
</head>
<body>
<main>
<h1>Sidebar Update Available Banner</h1>
<p class="intro">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.</p>
<section class="proposal">
<h2>Footer Slot Text</h2>
<p class="proposal-copy">Discreet release notice at the bottom of the sidebar, using the footer slot instead of adding another row.</p>
<div class="sidebar">
<div class="sidebar-inner">
<div class="actions-row">
<div class="status"><span class="dot"></span><span class="name">Koolook_v03</span><span class="state">saved</span></div>
<button class="btn">L</button><button class="btn">S</button><button class="btn">A/B</button>
</div>
<div class="divider"></div>
<div class="actions-row"><span class="label">Tools</span><button class="btn">I</button><button class="btn">H</button><button class="btn">P</button></div>
<div class="divider"></div>
<div class="search">Search nodes and workflows...</div>
<div class="divider"></div>
<div class="actions-row"><span class="label">Nodes</span><button class="btn">R</button><button class="btn">C</button><button class="btn green">+</button></div>
<div class="divider"></div>
<div class="actions-row"><span class="label">Workflows</span><button class="btn">S</button><button class="btn">M</button></div>
<div class="divider"></div>
<div class="tree">
<div class="tree-row"><span class="chev">v</span>Nodes (favorites)<span class="count">11</span></div>
<div class="tree-row indent dim"><span class="chev">></span>image<span class="count">1</span></div>
<div class="tree-row indent dim"><span class="chev">></span>Koolook<span class="count">7</span></div>
<div class="divider"></div>
<div class="tree-row"><span class="chev">v</span>Workflows<span class="count">6</span></div>
<div class="tree-row indent dim"><span class="chev">></span>Kforge Labs Workflows<span class="count">2</span></div>
<div class="tree-row indent dim"><span class="chev">></span>_Modules<span class="count">3</span></div>
</div>
<div class="inline-update">
<span>Update Available</span>
<a href="https://github.com/malkuthro/ComfyUI-Koolook/releases" target="_blank" rel="noopener noreferrer">Release notes</a>
</div>
</div>
</div>
<p class="notes">Lowest visual weight. This is the closest match if we want the notice to live where the hidden dev footer would otherwise be.</p>
</section>
</main>
</body>
</html>
+30
View File
@@ -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()
+1 -1
View File
@@ -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)
+89
View File
@@ -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");
+15
View File
@@ -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())
+14
View File
@@ -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
+5 -1
View File
@@ -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
+11 -1
View File
@@ -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
+111
View File
@@ -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");
}