| // SPDX-FileCopyrightText: 2025-2026 Kforge Labs <https://github.com/malkuthro/ComfyUI-Koolook> | |
| // SPDX-License-Identifier: GPL-3.0-only | |
| // ============================================================================= | |
| // Koolook curated-nodes sidebar β entry point. | |
| // | |
| // This file boots the sidebar; module bodies live in `web/sidebar/`. ComfyUI | |
| // discovers the extension via `WEB_DIRECTORY = "./web"` (set in __init__.py) | |
| // and calls `app.registerExtension(...).setup()` once during frontend startup. | |
| // ============================================================================= | |
| import { app } from "../../../scripts/app.js"; | |
| import { | |
| TAB_ID, | |
| TAB_TITLE, | |
| TAB_TOOLTIP, | |
| TAB_ICON, | |
| WORKFLOWS_FALLBACK_KEY, | |
| criticalToast, | |
| toast, | |
| } from "./sidebar/constants.js"; | |
| import { | |
| seedStarterPresetIfNeeded, | |
| startPeriodicAutosave, | |
| markStateSaved, | |
| getCurrentPresetName, | |
| detectBootDrift, | |
| SNAPSHOT_KIND, | |
| SNAPSHOT_VERSION, | |
| writePreset, | |
| } from "./sidebar/snapshot.js"; | |
| import { | |
| loadWorkflowsStore, | |
| seedWorkflowDefaultsIfNeeded, | |
| clearOfflineFallback, | |
| } from "./sidebar/workflows_store.js"; | |
| import { loadUserPicks } from "./sidebar/picks_store.js"; | |
| import { showConfirmModal } from "./sidebar/modals.js"; | |
| import { renderSidebar } from "./sidebar/tree.js"; | |
| import { patchCanvasMenu } from "./sidebar/canvas_menu.js"; | |
| import { claimSidebarRegistration } from "./sidebar/extension_guard.js"; | |
| function wireOfflineFallbackRecovery(initialBlob) { | |
| criticalToast( | |
| "Offline workflow data found in browser localStorage from a " + | |
| "previous /userdata outage. If workflows you saved earlier are " + | |
| "missing now, click Restore to save them as a snapshot β or " + | |
| "Copy details for the raw JSON. Discard removes the offline " + | |
| "copy permanently.", | |
| { | |
| copyText: initialBlob, | |
| actions: [ | |
| { | |
| label: "Restore as snapshot", | |
| primary: true, | |
| busyLabel: "Restoringβ¦", | |
| onClick: async () => { | |
| // Re-read at click time β the user may have already | |
| // discarded the blob from another tab; `null` means | |
| // there's nothing left to restore and we should | |
| // just dismiss. | |
| const blob = localStorage.getItem(WORKFLOWS_FALLBACK_KEY); | |
| if (!blob) { | |
| toast("Offline copy was already cleared."); | |
| return { dismiss: true }; | |
| } | |
| let fallbackStore; | |
| try { | |
| fallbackStore = JSON.parse(blob); | |
| } catch (e) { | |
| console.error("[Koolook] offline fallback parse failed:", e); | |
| toast("Offline copy is unparseable β copy the raw JSON instead."); | |
| return { dismiss: false }; | |
| } | |
| const isoNow = new Date().toISOString(); | |
| const tsForFile = isoNow.replace(/[:.]/g, "-"); | |
| const fileName = `recovered-${tsForFile}`; | |
| // Wrap as a full snapshot envelope so the recovered | |
| // file is a first-class snapshot the user can | |
| // inspect / Load via the regular Snapshot UI. Picks | |
| // come from the live store (NOT the offline blob β | |
| // the offline blob only holds workflows) so that | |
| // loading the recovered snapshot later wouldn't | |
| // wipe whatever picks the user has accumulated | |
| // since the outage. | |
| const envelope = { | |
| kind: SNAPSHOT_KIND, | |
| version: SNAPSHOT_VERSION, | |
| name: `Offline recovery Β· ${isoNow}`, | |
| exportedAt: isoNow, | |
| picks: loadUserPicks(), | |
| workflows: fallbackStore, | |
| }; | |
| try { | |
| await writePreset(fileName, envelope); | |
| } catch (e) { | |
| console.error("[Koolook] offline restore write failed:", e); | |
| toast(`Could not save recovery snapshot: ${e.message}`); | |
| return { dismiss: false }; | |
| } | |
| clearOfflineFallback(); | |
| toast( | |
| `Saved offline copy as snapshot "${fileName}". ` + | |
| `Open Snapshot β Load to inspect or restore.`, | |
| 5500 | |
| ); | |
| return { dismiss: true }; | |
| }, | |
| }, | |
| { | |
| label: "Discard offline copy", | |
| busyLabel: "Discardingβ¦", | |
| onClick: () => new Promise((resolve) => { | |
| showConfirmModal({ | |
| title: "Discard offline copy?", | |
| message: | |
| "This permanently removes the localStorage " + | |
| "fallback. Make sure you've already recovered " + | |
| "anything you needed β once cleared, this " + | |
| "data cannot be retrieved.", | |
| confirmLabel: "Discard", | |
| danger: true, | |
| onConfirm: () => { | |
| clearOfflineFallback(); | |
| toast("Offline copy discarded."); | |
| resolve({ dismiss: true }); | |
| }, | |
| // Cancel resolves with `{ dismiss: false }` so the | |
| // toast button re-enables and the user can Dismiss | |
| // or Restore from the still-open recovery banner | |
| // instead of being stuck in a "Discardingβ¦" state. | |
| onCancel: () => resolve({ dismiss: false }), | |
| }); | |
| }), | |
| }, | |
| ], | |
| } | |
| ); | |
| } | |
| // Client-side duplicate-install guard (#162). The backend `__init__.py` | |
| // guard already prevents two Koolook installations from publishing | |
| // competing `./web/` folders to ComfyUI (the non-winning install drops | |
| // its `WEB_DIRECTORY`), so this path normally fires only once. But a | |
| // downgraded sibling install on an older Koolook version without the | |
| // backend guard, or a stale Manager-side cache that loads two | |
| // `koolook_sidebar.js` files anyway, would land here twice and the | |
| // second `app.registerExtension(...)` would silently shadow the first | |
| // in some ComfyUI builds. The claim helper sets a window sentinel on | |
| // first call so the second load skips registration entirely. | |
| if (!claimSidebarRegistration(window)) { | |
| console.warn( | |
| "[Koolook] duplicate Kforge Labs install detected β this file was loaded twice. " + | |
| "Check ComfyUI's custom_nodes/ for both `koolook/` and `ComfyUI-Koolook/` folders " + | |
| "(or any other Koolook copy) and remove one. The first registration stays live." | |
| ); | |
| // Defer the user-facing toast until the next microtask β `criticalToast` | |
| // appends to `document.body`, which exists at script-eval time in ComfyUI | |
| // but the surrounding style sheet (`ensureStyle()`) is installed inside | |
| // `renderPanel`. Queuing the toast lets the user see it as soon as the | |
| // sidebar tab opens, even on a duplicate install where the second | |
| // `setup()` never runs. | |
| queueMicrotask(() => { | |
| try { | |
| criticalToast( | |
| "Kforge Labs is installed twice in ComfyUI's custom_nodes/. " + | |
| "Two copies (typically `koolook/` from the ComfyUI Manager / Registry " + | |
| "and `ComfyUI-Koolook/` from a git clone) both register the same " + | |
| "sidebar tab and write to the same /userdata file β running both " + | |
| "silently corrupts your workflow store. Remove one of the folders " + | |
| "and restart ComfyUI. See the server log for the resolved paths." | |
| ); | |
| } catch (e) { | |
| // No usable DOM yet β the console.warn above already carries | |
| // the essential signal. Don't escalate. | |
| } | |
| }); | |
| } else { | |
| app.registerExtension({ | |
| name: "koolook.curated_sidebar", | |
| async setup() { | |
| if (!app.extensionManager || !app.extensionManager.registerSidebarTab) { | |
| console.warn("[Koolook] extensionManager.registerSidebarTab not available; sidebar not registered."); | |
| return; | |
| } | |
| // Seeds the bundled starter preset into the user's snapshot library | |
| // on first run (replaces the legacy `curated_defaults.json` localStorage | |
| // pick-seed). Async-fires server I/O β kept off the critical path so | |
| // sidebar tab registration isn't blocked behind it on a slow server. | |
| seedStarterPresetIfNeeded(); | |
| const loadResult = await loadWorkflowsStore(); | |
| // Skip workflow seeding when /userdata file is corrupt β we don't want | |
| // to overwrite a recoverable-but-unparseable file with stock defaults. | |
| if (!loadResult.corrupt) { | |
| await seedWorkflowDefaultsIfNeeded(); | |
| } | |
| // Offline-fallback recovery toast β fires when /userdata loaded | |
| // successfully but a stale localStorage blob from a past outage | |
| // still exists. Two recovery actions: | |
| // β’ Restore as snapshot β wraps the offline workflows into a | |
| // full snapshot envelope (with current picks so a later Load | |
| // wouldn't wipe them) and writes it as `recovered-<iso>.json` | |
| // into the snapshot library. Clears the fallback key on | |
| // success so the banner self-extinguishes. | |
| // β’ Discard offline copy β confirm-then-clear. Same end state | |
| // for the banner, but the user explicitly accepts data loss. | |
| // Wired here (not in `loadWorkflowsStore`) because the action | |
| // handlers need `writePreset`, `loadUserPicks`, and | |
| // `showConfirmModal` β pulling those into workflows_store would | |
| // create a circular import via snapshot.js / modals.js. | |
| if (loadResult.fallbackBlob) { | |
| wireOfflineFallbackRecovery(loadResult.fallbackBlob); | |
| } | |
| // Baseline the saved-state fingerprint at session start IFF the | |
| // tracker says a preset is currently loaded AND we don't already | |
| // have a baseline persisted from a previous session. The "saved | |
| // fingerprint persists across reloads" path is more accurate, but | |
| // first-ever-session needs this seeding to avoid showing "unsaved" | |
| // immediately for users who closed the tab on a clean state. | |
| // Without this, the indicator would flicker "unsaved β saved" the | |
| // first time the user clicks Save (since markStateSaved baselines | |
| // there). With it, "saved" is shown from the start. Trade-off: if | |
| // the user closed mid-edit (state on /userdata diverges from the | |
| // tracked preset's content), we'll show "saved" briefly until they | |
| // mutate again β acceptable for an indicator. | |
| if (getCurrentPresetName() && !localStorage.getItem("koolook.snapshot.savedFingerprint.v1")) { | |
| markStateSaved(); | |
| } | |
| // Boot-time tracked-snapshot drift check (#161). When a preset is | |
| // tracked, compare the named snapshot file on disk against the | |
| // live /userdata + picks state. On mismatch, flag drift β the | |
| // status pill flips to "drifted (reload?)" and periodic auto- | |
| // saves redirect to `_unsaved_autosave/` so the named preset's | |
| // recovery folder is never overwritten with the (potentially | |
| // corrupt) live state. | |
| // | |
| // Runs AFTER the localStorage fingerprint baseline above so the | |
| // drift flag wins precedence over the fingerprint match β see | |
| // `getSnapshotStatus()` for the precedence rationale. | |
| // | |
| // Failure here is silent (logged, not toasted): a missing file or | |
| // unreachable server are distinct failure modes the user already | |
| // has surface for elsewhere. Drift specifically means "both | |
| // present, both differ". | |
| const trackedNameForDrift = getCurrentPresetName(); | |
| if (trackedNameForDrift) { | |
| try { | |
| await detectBootDrift(trackedNameForDrift); | |
| } catch (e) { | |
| console.warn("[Koolook] boot drift check threw:", e); | |
| } | |
| } | |
| app.extensionManager.registerSidebarTab({ | |
| id: TAB_ID, | |
| title: TAB_TITLE, | |
| tooltip: TAB_TOOLTIP, | |
| icon: TAB_ICON, | |
| type: "custom", | |
| render: (el) => renderSidebar(el), | |
| }); | |
| patchCanvasMenu(); | |
| // Periodic defensive auto-save β fires every 5 minutes if state has | |
| // changed since the last successful auto-save. Keeps last 5 in the | |
| // snapshot library as `_autosave_periodic_<iso>.json`. Started AFTER | |
| // sidebar registration so it doesn't compete with first-render work, | |
| // and uses an internal grace period before the first tick so the | |
| // load/seed flows above settle first. | |
| startPeriodicAutosave(); | |
| }, | |
| }); | |
| } | |