refactor(sw): bidirectional SW status sync with IndexedDB persistence

Replace fire-and-forget key delivery with a sync-status protocol that
verifies key consistency between main thread and Service Worker using
SHA-256 fingerprints via MessageChannel request-response.

- SW (ts/sw.ts): persist keys in IndexedDB, auto-restore on activate;
  respond to sync-status queries with loaded keys + fingerprints;
  support cryptio-clear-keys to wipe memory + DB.
- New ts/utils/swSync.ts: single entry point for SW registration,
  key sync, and status verification. Controller change listener
  re-syncs on SW restart. Deduplicates concurrent sync calls.
- Deleted ts/utils/swKeySender.ts (replaced by swSync.ts).
- Remove SW registration (~55 lines) from cryptioSaveImage.ts;
  setup() now calls syncSWStatus().
- uploadUtils.ts: drop 5-retry preview loop (~6.4s worst case);
  single attempt with syncSWStatus fallback on failure.
- cryptioSettings.ts: sync after key changes; clearSWKeys() on
  Clear All.
- cryptioUploadImage/Video.ts: syncSWStatus in setup + preview
  fallback.
- types.ts: add SWStatus interface.
This commit is contained in:
Suzu008
2026-05-30 22:29:45 +08:00
parent cf62be54eb
commit a58a5b8917
22 changed files with 1161 additions and 855 deletions
+1 -1
View File
@@ -2,7 +2,6 @@
__pycache__/
*.py[cod]
*$py.class
/js
# OSX useful to ignore
*.DS_Store
.AppleDouble
@@ -102,6 +101,7 @@ cookiecutter-pypackage-env/
*.code-workspace
# Frontend extension
js/
node_modules/
.env
.env.local
+179
View File
@@ -0,0 +1,179 @@
# CryptIO Development Guide
> **Keep this file in sync.** When architecture, commands, references, or conventions change, update this file so future sessions stay accurate.
## Quick Commands
```bash
# Python: dev install (live-reload on ComfyUI restart)
pip install -e .[dev]
# TypeScript: compile to js/
cd ts && pnpm build
# TypeScript: type-check only (no emit)
cd ts && pnpm check
# Lint (ruff check --fix) + format (ruff format)
pre-commit run --all-files
# Lint only (CI does this)
ruff check .
```
**To test changes**: restart ComfyUI. Python changes are picked up via editable install. JS changes are served from `./js` (set by `WEB_DIRECTORY`).
---
## Architecture
### Encryption Flow
```
Upload: Browser --[server public key]--> AES-GCM encrypt --> Server decrypts with server private key
Output: Server --[client public key]--> AES-GCM encrypt --> Browser decrypts with client private key
KeyEx: Browser sends client pubkey --> Server sends encrypted server keypair (RSA+AES-CBC)
```
- **Server keypair** → `keys/keys.json` (auto-generated on first run, gitignored via `/keys`)
- **Key store at runtime** → ComfyUI's system user directory (`folder_paths.get_system_user_directory()/cryptio/keys/`), NOT the repo's `keys/`
- **Client keypair** → browser `localStorage` (`cryptio_client_keypair`, `cryptio_server_keys`)
- **Crypto primitives**: RSA-2048 for key wrapping, AES-GCM for data, AES-CBC for key exchange
### Nodes (all V3 `io.ComfyNode`)
All nodes use the V3 pattern: `define_schema()` (Schema + widget inputs), `execute()`, `fingerprint_inputs()`, `validate_inputs()`.
| File | Nodes | Output Types |
|------|-------|-------------|
| `text_crypt.py` | TextEncrypt, TextDecrypt | String |
| `upload_image.py` | UploadImageCryptIO | Image + Mask |
| `save_image.py` | SaveImageCryptIO, PreviewImageCryptIO | UI (cryptio_images) |
| `upload_video.py` | UploadVideoCryptIO | Video |
| `save_video.py` | SaveVideoCryptIO, PreviewVideoCryptIO | UI (cryptio_images, animated) |
### Extension Registration
`__init__.py` uses `comfy_entrypoint()` returning a `ComfyExtension` subclass. Nodes are registered via `get_node_list()` returning `NODE_CLASS_MAPPINGS.values()`. The old `NODE_CLASS_MAPPINGS` + `NODE_DISPLAY_NAME_MAPPINGS` dicts are still defined in `nodes.py` for backwards compat.
### Key Manager
`KeyManager` is a singleton (`src/cryptio/utils/key_utils.py`). It caches keys in memory and checks file mtime to detect external changes. The global instance is `_key_manager`.
### File Layout
```
__init__.py # WEB_DIRECTORY = "./js", comfy_entrypoint, ComfyExtension
src/cryptio/
nodes.py # Node class imports + NODE_CLASS_MAPPINGS/NODE_DISPLAY_NAME_MAPPINGS
api.py # HTTP routes: /cryptio/exchange_keys, /upload_encrypted, /view_encrypted
text_crypt.py # TextEncrypt, TextDecrypt
upload_image.py # UploadImageCryptIO
save_image.py # SaveImageCryptIO, PreviewImageCryptIO
upload_video.py # UploadVideoCryptIO
save_video.py # SaveVideoCryptIO, PreviewVideoCryptIO
utils/
crypto_utils.py # RSA+AES-GCM hybrid encrypt/decrypt, AES-CBC helpers
key_utils.py # KeyManager singleton
ts/ # TypeScript source (compiled to js/)
cryptioSettings.ts # Settings panel UI
cryptioUploadImage.ts / cryptioUploadVideo.ts # Upload nodes
cryptioSaveImage.ts / cryptioSaveVideo.ts # Save/Preview nodes
textEncrypt.ts # Text encrypt node
utils/ # cryptoKeys, cryptoUtils, uploadUtils, fileUtils, loaders
js/ # Compiled JS (served to browser, WEB_DIRECTORY); not in .gitignore
```
---
## References
All references are **read-only** — do not modify files in these locations.
- **ComfyUI node development skills** available in `.agents/skills/` (basics, inputs, outputs, datatypes, lifecycle, frontend, advanced, migration, packaging). Use the `skill` tool to load them when needed.
- **ComfyUI**: `D:\CODE\ComfyUI` — the ComfyUI installation directory. Reference for `folder_paths`, `comfy_api`, base types, and node infrastructure.
- **ComfyUI frontend source**: `D:\CODE\ComfyUI_frontend` — consult when debugging frontend integration (settings system, node UI, extension APIs).
## CI
- **build-pipeline.yml**: runs on PR to main/master — `ruff check .` then `pytest tests/`
- **validate.yml**: runs node-diff for backwards compatibility on PR
- **publish_node.yml**: publishes to Comfy registry
Note: `tests/` directory exists but is currently empty.
---
## Settings / Frontend Customization
### SettingCustomRenderer API
CryptIO uses custom DOM rendering for settings instead of built-in toggle/select types:
```typescript
// type must be a function returning HTMLElement, NOT "boolean" or "combo"
{
id: "cryptio.key_management",
type: renderCryptIOSettings, // (name, setter, value, attrs?) => HTMLElement
defaultValue: null,
category: ["CryptIO🔒", "Key Management"],
}
```
Called by `FormItem.vue` -> `CustomFormValue.vue` in ComfyUI frontend.
### Category / Nav Structure
- `category` **must have >= 2 levels** for a setting to appear as a nav tree node. Single-element `["CryptIO🔒"]` becomes a "floating" leaf under "Other".
- Dedicated nav group for CryptIO required modifying `useSettingUI.ts` in ComfyUI frontend — not part of this repo.
### ComfyUI APIs (prefer these over raw DOM)
```typescript
// Toasts
app.extensionManager.toast.add({ severity, summary, detail, life })
// Confirmation dialogs (instead of window.confirm)
app.extensionManager.dialog.confirm({ title, message })
// Setting access
app.extensionManager.setting.set("id", value)
```
### Styling
Inject a `<style>` element in `setup()`. Use ComfyUI CSS variables:
| Variable | Purpose |
|----------|---------|
| `--p-primary-color` | Accent / button primary |
| `--base-background` | Card background |
| `--base-foreground` | Text color |
| `--secondary-background` | Subtle card background |
| `--border-default` / `--border-subtle` | Borders |
| `--text-muted` | Secondary text |
| `--destructive-background` | Danger/delete |
| `--success-background` | Success |
Use `pi pi-*` classes for PrimeIcons (e.g. `pi pi-refresh`, `pi pi-download`, `pi pi-trash`).
---
## Interaction Rules
- **Answer first, then act.** When the user asks a question or reports an issue, always respond to the question first before making any code changes. Do not jump directly into editing files. This includes informational queries, clarifications, and requests for references.
- When the user assigns a task, proceed directly — no need to pre-confirm.
## Gotchas
- **ComfyUI restart needed** for any Python changes or recompiled JS. No hot reload.
- **`category` must have >= 2 levels** for a setting to appear as a non-leaf nav node.
- The `app` singleton from `../../scripts/app.js` may not have `extensionManager` populated at module load time. Access it lazily inside functions.
- Encrypted files are named `<original>.encrypted` on disk. The browser strips `.encrypted` suffix after decryption for display.
- All `.encrypted` files in the input directory appear as selectable Combo options in Upload nodes.
- **Save/Preview nodes** output encrypted files to disk; decryption happens in the browser via the TS frontend.
- **Upload nodes** read encrypted files from disk, decrypt them server-side using `_key_manager.server_private_key`.
- Save nodes require client public key (established via key exchange when first upload occurs). They will raise `ValueError` if it's missing.
- `ruff` config targets `py39` in `pyproject.toml` but `requires-python` is `>=3.10`. Do not use `match`/`case` without verifying.
+11 -3
View File
@@ -1,14 +1,22 @@
"""Top-level package for cryptio."""
from comfy_api.latest import ComfyExtension, io
from .src.cryptio.nodes import NODE_CLASS_MAPPINGS as _NODE_CLASS_MAPPINGS
WEB_DIRECTORY = "./js"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
__all__ = ["WEB_DIRECTORY", "comfy_entrypoint"]
__author__ = """ComfyUI-CryptoIO"""
__email__ = "thesoup@foxmail.com"
__version__ = "0.0.1"
from .src.cryptio.nodes import NODE_CLASS_MAPPINGS
from .src.cryptio.nodes import NODE_DISPLAY_NAME_MAPPINGS
class CryptIOExtension(ComfyExtension):
async def get_node_list(self) -> list[type[io.ComfyNode]]:
return list(_NODE_CLASS_MAPPINGS.values())
async def comfy_entrypoint() -> CryptIOExtension:
return CryptIOExtension()
-407
View File
@@ -1,407 +0,0 @@
/**
* CryptIO Settings - ComfyUI Settings Integration
* Rich key management UI using custom renderer in the settings panel
*/
//@ts-ignore
import { app as rawApp } from "../../scripts/app.js";
import { CLIENT_KEYPAIR_STORAGE_KEY, SERVER_KEYS_STORAGE_KEY, } from "./utils/cryptoKeys.js";
const app = rawApp;
const SETTINGS_STYLES = `
.cryptio-settings {
display: flex;
flex-direction: column;
gap: 18px;
width: 100%;
}
.cryptio-section-title {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted, #8a8a8a);
margin-bottom: 2px;
padding-bottom: 4px;
border-bottom: 1px solid var(--border-subtle, #3c3d42);
}
.cryptio-section-title-danger {
color: var(--destructive-background, #f75951);
border-bottom-color: var(--destructive-background, #f75951);
}
.cryptio-status-card {
padding: 12px 14px;
border-radius: 8px;
border: 1px solid var(--border-subtle, #3c3d42);
background: var(--secondary-background, #262729);
font-size: 13px;
display: flex;
flex-direction: column;
gap: 8px;
}
.cryptio-status-row {
display: flex;
align-items: center;
gap: 8px;
}
.cryptio-status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.cryptio-status-label {
font-weight: 500;
color: var(--base-foreground, #fff);
}
.cryptio-status-detail {
color: var(--text-muted, #8a8a8a);
font-size: 12px;
margin-left: auto;
text-align: right;
}
.cryptio-btn-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.cryptio-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid var(--border-default, #494a50);
border-radius: 6px;
background: var(--base-background, #171718);
color: var(--base-foreground, #fff);
font-size: 13px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
line-height: 1.4;
}
.cryptio-btn:hover {
background: var(--secondary-background-hover, #313235);
border-color: var(--p-primary-color, #0b8ce9);
}
.cryptio-btn:active {
opacity: 0.85;
}
.cryptio-btn i {
font-size: 14px;
}
.cryptio-btn-primary {
background: var(--p-primary-color, #0b8ce9);
color: #fff;
border-color: var(--p-primary-color, #0b8ce9);
}
.cryptio-btn-primary:hover {
background: var(--p-primary-hover-color, #185a8b);
border-color: var(--p-primary-hover-color, #185a8b);
}
.cryptio-btn-danger {
background: var(--destructive-background, #b33a3a);
color: #fff;
border-color: var(--destructive-background, #b33a3a);
}
.cryptio-btn-danger:hover {
filter: brightness(1.15);
}
`;
function injectStyles() {
if (document.getElementById("cryptio-settings-styles"))
return;
const style = document.createElement("style");
style.id = "cryptio-settings-styles";
style.textContent = SETTINGS_STYLES;
document.head.appendChild(style);
}
// ──────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────
function getExtMgr() {
return app.extensionManager;
}
function showToast(message, severity = "info") {
const em = getExtMgr();
if (em?.toast?.add) {
em.toast.add({ severity, summary: "CryptIO", detail: message, life: 3000 });
}
}
async function showConfirm(title, message) {
const em = getExtMgr();
if (em?.dialog?.confirm) {
try {
const result = await em.dialog.confirm({ title, message });
return result === true;
}
catch {
return false;
}
}
return window.confirm(message);
}
function downloadJSON(data, filename) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function readJSONFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target?.result);
resolve(data);
}
catch {
reject(new Error("Invalid JSON file"));
}
};
reader.onerror = () => reject(new Error("Failed to read file"));
reader.readAsText(file);
});
}
function createFileInput(id, onChange) {
const existing = document.getElementById(id);
if (existing)
existing.remove();
const input = document.createElement("input");
input.type = "file";
input.id = id;
input.accept = ".json";
input.style.display = "none";
input.addEventListener("change", (event) => {
onChange(event);
input.remove();
});
document.body.appendChild(input);
return input;
}
function triggerFileUpload(id, handler) {
const input = createFileInput(id, handler);
input.click();
}
function getKeyInfo(storageKey) {
const raw = localStorage.getItem(storageKey);
if (!raw)
return { present: false, keyType: "" };
try {
const data = JSON.parse(raw);
const pubKey = data.publicKey || "";
if (pubKey.includes("RSA"))
return { present: true, keyType: "RSA" };
if (pubKey.includes("PUBLIC"))
return { present: true, keyType: "Public Key" };
return { present: true, keyType: "Configured" };
}
catch {
return { present: false, keyType: "" };
}
}
// ──────────────────────────────────────────────
// Action handlers
// ──────────────────────────────────────────────
async function handleGenerate() {
const confirmed = await showConfirm("Generate New Client Keys", "This will overwrite your existing client keypair and cannot be undone. Continue?");
if (!confirmed)
return;
try {
const { generateClientKeyPair } = await import("./utils/cryptoKeys.js");
const keyPair = await generateClientKeyPair();
localStorage.setItem(CLIENT_KEYPAIR_STORAGE_KEY, JSON.stringify(keyPair));
showToast("New client keys generated", "success");
}
catch (error) {
showToast(`Failed to generate keys: ${error}`, "error");
}
}
async function handleClear() {
const confirmed = await showConfirm("Clear All Keys", "This will remove all CryptIO keys from localStorage. This action cannot be undone.");
if (!confirmed)
return;
localStorage.removeItem(CLIENT_KEYPAIR_STORAGE_KEY);
localStorage.removeItem(SERVER_KEYS_STORAGE_KEY);
showToast("All keys cleared", "success");
}
function handleDownloadClient() {
const raw = localStorage.getItem(CLIENT_KEYPAIR_STORAGE_KEY);
if (raw) {
downloadJSON(JSON.parse(raw), "cryptio-client-keypair.json");
showToast("Client keys downloaded", "success");
}
else {
showToast("No client keys found", "error");
}
}
function handleDownloadServer() {
const raw = localStorage.getItem(SERVER_KEYS_STORAGE_KEY);
if (raw) {
downloadJSON(JSON.parse(raw), "cryptio-server-keys.json");
showToast("Server keys downloaded", "success");
}
else {
showToast("No server keys found", "error");
}
}
async function handleUploadClient(event) {
const file = event.target.files?.[0];
if (!file)
return;
try {
const data = await readJSONFile(file);
if (data.publicKey && data.privateKey) {
localStorage.setItem(CLIENT_KEYPAIR_STORAGE_KEY, JSON.stringify(data));
showToast("Client keys uploaded", "success");
}
else {
showToast("Invalid key format — must contain publicKey and privateKey", "error");
}
}
catch (error) {
showToast(`Failed to upload: ${error}`, "error");
}
}
async function handleUploadServer(event) {
const file = event.target.files?.[0];
if (!file)
return;
try {
const data = await readJSONFile(file);
if (data.publicKey && data.privateKey) {
localStorage.setItem(SERVER_KEYS_STORAGE_KEY, JSON.stringify(data));
showToast("Server keys uploaded", "success");
}
else {
showToast("Invalid key format — must contain publicKey and privateKey", "error");
}
}
catch (error) {
showToast(`Failed to upload: ${error}`, "error");
}
}
// ──────────────────────────────────────────────
// DOM builders
// ──────────────────────────────────────────────
function el(tag, className, text) {
const e = document.createElement(tag);
e.className = className;
if (text !== undefined)
e.textContent = text;
return e;
}
function makeSectionTitle(text, variant) {
if (variant === "danger")
return el("div", "cryptio-section-title cryptio-section-title-danger", text);
return el("div", "cryptio-section-title", text);
}
function makeButton(text, icon, variant, onClick) {
const btn = document.createElement("button");
btn.className = "cryptio-btn";
if (variant === "primary")
btn.classList.add("cryptio-btn-primary");
if (variant === "danger")
btn.classList.add("cryptio-btn-danger");
const i = document.createElement("i");
i.className = icon;
btn.appendChild(i);
btn.appendChild(document.createTextNode(" " + text));
btn.addEventListener("click", onClick);
return btn;
}
function makeButtonRow(buttons) {
const row = el("div", "cryptio-btn-row");
buttons.forEach((b) => row.appendChild(b));
return row;
}
function makeStatusCard(items) {
const card = el("div", "cryptio-status-card");
items.forEach((item) => {
const row = el("div", "cryptio-status-row");
const dot = el("span", "cryptio-status-dot");
dot.style.backgroundColor = item.present
? "var(--success-background, #00cd72)"
: "var(--text-muted, #8a8a8a)";
row.appendChild(dot);
row.appendChild(el("span", "cryptio-status-label", item.label));
row.appendChild(el("span", "cryptio-status-detail", item.detail));
card.appendChild(row);
});
return card;
}
// ──────────────────────────────────────────────
// Custom renderer (SettingCustomRenderer)
// ──────────────────────────────────────────────
function renderCryptIOSettings(_name, _setter, _value, _attrs) {
const container = el("div", "cryptio-settings");
const clientInfo = getKeyInfo(CLIENT_KEYPAIR_STORAGE_KEY);
const serverInfo = getKeyInfo(SERVER_KEYS_STORAGE_KEY);
// ── Key Status ──
container.appendChild(makeSectionTitle("Key Status"));
container.appendChild(makeStatusCard([
{
present: clientInfo.present,
label: "Client Key",
detail: clientInfo.present
? `${clientInfo.keyType} keypair configured`
: "Not configured",
},
{
present: serverInfo.present,
label: "Server Key",
detail: serverInfo.present
? `${serverInfo.keyType} keypair configured`
: "Not configured",
},
]));
// ── Client Keys ──
container.appendChild(makeSectionTitle("Client Keys"));
container.appendChild(makeButtonRow([
makeButton("Generate New", "pi pi-refresh", "primary", () => handleGenerate()),
makeButton("Download", "pi pi-download", "default", () => handleDownloadClient()),
makeButton("Upload", "pi pi-upload", "default", () => triggerFileUpload("cryptio-client-upload", handleUploadClient)),
]));
// ── Server Keys ──
container.appendChild(makeSectionTitle("Server Keys"));
container.appendChild(makeButtonRow([
makeButton("Download", "pi pi-download", "default", () => handleDownloadServer()),
makeButton("Upload", "pi pi-upload", "default", () => triggerFileUpload("cryptio-server-upload", handleUploadServer)),
]));
// ── Danger Zone ──
container.appendChild(makeSectionTitle("Danger Zone", "danger"));
container.appendChild(makeButtonRow([
makeButton("Clear All Keys", "pi pi-trash", "danger", () => handleClear()),
]));
return container;
}
// ──────────────────────────────────────────────
// Extension registration
// ──────────────────────────────────────────────
app.registerExtension({
name: "cryptio.Settings",
settings: [
{
id: "cryptio.category",
name: "CryptIO Settings",
type: "hidden",
defaultValue: "",
category: ["CryptIO🔒", "Key Management"],
},
{
id: "cryptio.key_management",
name: "Key Management",
type: renderCryptIOSettings,
defaultValue: null,
category: ["CryptIO🔒", "Key Management"],
},
],
async setup() {
injectStyles();
console.log("[CryptIO] Settings extension loaded");
},
});
+4 -1
View File
@@ -11,7 +11,10 @@ readme = "README.md"
license = { text = "GNU General Public License v3" }
requires-python = ">=3.10"
classifiers = []
dependencies = []
dependencies = [
"av",
"cryptography",
]
[project.optional-dependencies]
+59
View File
@@ -0,0 +1,59 @@
{
"version": 1,
"skills": {
"comfyui-node-advanced": {
"source": "jtydhr88/comfyui-custom-node-skills",
"sourceType": "github",
"skillPath": "plugins/comfyui-custom-nodes/skills/comfyui-node-advanced/SKILL.md",
"computedHash": "c096e68c33360e28abe06b69e531b83f3dc826a40ea21e46b4c8a28f1fa4aefd"
},
"comfyui-node-basics": {
"source": "jtydhr88/comfyui-custom-node-skills",
"sourceType": "github",
"skillPath": "plugins/comfyui-custom-nodes/skills/comfyui-node-basics/SKILL.md",
"computedHash": "160a565a09d779b4e19cd0c61b9ee1d5e9491e0fe54b648632c15be6e14855a0"
},
"comfyui-node-datatypes": {
"source": "jtydhr88/comfyui-custom-node-skills",
"sourceType": "github",
"skillPath": "plugins/comfyui-custom-nodes/skills/comfyui-node-datatypes/SKILL.md",
"computedHash": "d9c9130075d005c82c2dd406ffde1bff3035bcca4276c3ce79c40df356d3b771"
},
"comfyui-node-frontend": {
"source": "jtydhr88/comfyui-custom-node-skills",
"sourceType": "github",
"skillPath": "plugins/comfyui-custom-nodes/skills/comfyui-node-frontend/SKILL.md",
"computedHash": "1853a9feb9c2c0bc759c9daa7cafd1114d01a87bfdbc2a94c595dda809d62663"
},
"comfyui-node-inputs": {
"source": "jtydhr88/comfyui-custom-node-skills",
"sourceType": "github",
"skillPath": "plugins/comfyui-custom-nodes/skills/comfyui-node-inputs/SKILL.md",
"computedHash": "29888eb83e1173aed0627b2c106f7deaaa7ca59a547e6f83ff9893ea9bbd73dd"
},
"comfyui-node-lifecycle": {
"source": "jtydhr88/comfyui-custom-node-skills",
"sourceType": "github",
"skillPath": "plugins/comfyui-custom-nodes/skills/comfyui-node-lifecycle/SKILL.md",
"computedHash": "a09eac7fdd8c3978f425bc2dcba18dd5a47641f342de72d8949814206aedddc9"
},
"comfyui-node-migration": {
"source": "jtydhr88/comfyui-custom-node-skills",
"sourceType": "github",
"skillPath": "plugins/comfyui-custom-nodes/skills/comfyui-node-migration/SKILL.md",
"computedHash": "213863c4d3bac3aa34b38507c0fbca7eb774b50df6a20e3cba32ab3ca0057569"
},
"comfyui-node-outputs": {
"source": "jtydhr88/comfyui-custom-node-skills",
"sourceType": "github",
"skillPath": "plugins/comfyui-custom-nodes/skills/comfyui-node-outputs/SKILL.md",
"computedHash": "3efce7a21b4e02f91ec9353cb2431ebc77044bff0f68ef7d81feb744f86946b3"
},
"comfyui-node-packaging": {
"source": "jtydhr88/comfyui-custom-node-skills",
"sourceType": "github",
"skillPath": "plugins/comfyui-custom-nodes/skills/comfyui-node-packaging/SKILL.md",
"computedHash": "a64a0353af48aeb414c55d3826a1a2d512604c497912503c538497f8311588dd"
}
}
}
+20
View File
@@ -174,3 +174,23 @@ async def view_encrypted_image(request):
except Exception as e:
print(f"Error viewing encrypted image: {e}")
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.get("/cryptio-sw.js")
async def serve_service_worker(request):
"""Serve the Service Worker script from root so its natural scope is '/'.
Served from /cryptio-sw.js (no subdirectory) → natural scope is /.
No Service-Worker-Allowed header needed.
"""
sw_path = os.path.join(os.path.dirname(__file__), "..", "..", "js", "sw.js")
if not os.path.exists(sw_path):
return web.json_response({"error": "Service Worker script not found"}, status=404)
with open(sw_path, "r", encoding="utf-8") as f:
content = f.read()
return web.Response(
text=content,
content_type="application/javascript",
)
+4 -4
View File
@@ -33,8 +33,8 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"SaveImageCryptIO": "Save Image 🔒",
"PreviewImageCryptIO": "Preview Image 🔒",
# 视频处理 deprecated, now defined in node schema
# "UploadVideoCryptIO": "Upload Video 🔒",
# "SaveVideoCryptIO": "Save Video 🔒",
# "PreviewVideoCryptIO": "Preview Video 🔒",
# 视频处理
"UploadVideoCryptIO": "Upload Video 🔒",
"SaveVideoCryptIO": "Save Video 🔒",
"PreviewVideoCryptIO": "Preview Video 🔒",
}
+127 -185
View File
@@ -5,196 +5,138 @@ import numpy as np
import torch
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from io import BytesIO
import folder_paths # pyright: ignore[reportMissingImports]
from comfy_api.latest import io
import folder_paths # pyright: ignore[reportMissingImports]
from .utils import _key_manager
from .utils.crypto_utils import encrypt_data_hybrid
class SaveImageCryptIO:
"""
加密图片保存节点,将图片保存为加密的 .png.encrypted 文件
"""
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
self.type = "output"
self.prefix_append = ""
self.compress_level = 4
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE",),
"filename_prefix": ("STRING", {"default": "ComfyUI"}),
"format": (["PNG", "JPG", "WEBP", "AVIF"],),
"quality": ("INT", {"default": 95, "min": 1, "max": 100, "step": 1}),
"auto_download": ("BOOLEAN", {"default": False}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ()
FUNCTION = "save_images"
OUTPUT_NODE = True
CATEGORY = "CryptIO🔒"
def save_images(self, images, filename_prefix="ComfyUI", format="PNG", quality=95, auto_download=False, prompt=None, extra_pnginfo=None):
"""
保存并加密图片
"""
filename_prefix += self.prefix_append
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]
)
results = list()
for batch_number, image in enumerate(images):
i = 255.0 * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
# 添加元数据 (仅PNG支持)
metadata = None
if format == "PNG" and (prompt is not None or extra_pnginfo is not None):
metadata = PngInfo()
if prompt is not None:
metadata.add_text("prompt", json.dumps(prompt))
if extra_pnginfo is not None:
for x in extra_pnginfo:
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
# 保存为指定格式到内存
from io import BytesIO
file_ext = format.lower()
if format == "JPG":
file_ext = "jpg"
format_key = "JPEG"
else:
format_key = format
buffer = BytesIO()
if format == "PNG":
img.save(buffer, format="PNG", pnginfo=metadata, compress_level=self.compress_level)
else:
# JPG/WEBP/AVIF ignore metadata for now
img.save(buffer, format=format_key, quality=quality)
image_data = buffer.getvalue()
# 加密数据
encrypted_data = encrypt_data(image_data)
# 保存加密数据到文件
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
file = f"{filename_with_batch_num}_{counter:05}_.{file_ext}.encrypted"
encrypted_path = os.path.join(full_output_folder, file)
with open(encrypted_path, "wb") as f:
f.write(encrypted_data)
results.append(
{"filename": file, "subfolder": subfolder, "type": self.type}
)
counter += 1
return {"ui": {"cryptio_images": results}}
class PreviewImageCryptIO:
"""
加密图片预览节点,类似SaveImage但用于临时预览
"""
def __init__(self):
self.output_dir = folder_paths.get_temp_directory()
self.type = "temp"
self.prefix_append = "_temp_" + ''.join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5))
self.compress_level = 1
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE",),
"format": (["PNG", "JPG", "WEBP", "AVIF"],),
"quality": ("INT", {"default": 95, "min": 1, "max": 100, "step": 1}),
"auto_download": ("BOOLEAN", {"default": False}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ()
FUNCTION = "save_images"
OUTPUT_NODE = True
CATEGORY = "CryptIO🔒"
def save_images(self, images, auto_download=False, filename_prefix="ComfyUI", format="PNG", quality=95, prompt=None, extra_pnginfo=None):
"""
保存并加密图片用于预览
"""
filename_prefix += self.prefix_append
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]
)
results = list()
for batch_number, image in enumerate(images):
i = 255.0 * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
# 添加元数据 (仅PNG支持)
metadata = None
if format == "PNG" and (prompt is not None or extra_pnginfo is not None):
metadata = PngInfo()
if prompt is not None:
metadata.add_text("prompt", json.dumps(prompt))
if extra_pnginfo is not None:
for x in extra_pnginfo:
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
# 保存为指定格式到内存
from io import BytesIO
file_ext = format.lower()
if format == "JPG":
file_ext = "jpg"
format_key = "JPEG"
else:
format_key = format
buffer = BytesIO()
if format == "PNG":
img.save(buffer, format="PNG", pnginfo=metadata, compress_level=self.compress_level)
else:
img.save(buffer, format=format_key, quality=quality)
image_data = buffer.getvalue()
# 加密数据
encrypted_data = encrypt_data(image_data)
# 保存加密数据到文件
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
file = f"{filename_with_batch_num}_{counter:05}_.{file_ext}.encrypted"
encrypted_path = os.path.join(full_output_folder, file)
with open(encrypted_path, "wb") as f:
f.write(encrypted_data)
results.append(
{"filename": file, "subfolder": subfolder, "type": self.type}
)
counter += 1
return {"ui": {"cryptio_images": results}}
def encrypt_data(data: bytes) -> bytes:
"""
使用混合加密方案加密数据(使用客户端公钥加密)
"""
# 使用客户端公钥加密(这样只有客户端能解密)
def _encrypt_data(data: bytes) -> bytes:
if not _key_manager.client_public_key:
raise ValueError("Client public key not found. Please upload an image first to exchange keys.")
return encrypt_data_hybrid(data, _key_manager.client_public_key)
def _encode_and_encrypt(image: Image.Image, format: str, quality: int, compress_level: int, prompt=None, extra_pnginfo=None):
"""Encode image to bytes and encrypt."""
metadata = None
if format == "PNG" and (prompt is not None or extra_pnginfo is not None):
metadata = PngInfo()
if prompt is not None:
metadata.add_text("prompt", json.dumps(prompt))
if extra_pnginfo is not None:
for x in extra_pnginfo:
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
file_ext = format.lower()
format_key = "JPEG" if format == "JPG" else format
buffer = BytesIO()
if format == "PNG":
image.save(buffer, format="PNG", pnginfo=metadata, compress_level=compress_level)
else:
image.save(buffer, format=format_key, quality=quality)
encrypted_data = _encrypt_data(buffer.getvalue())
return encrypted_data, file_ext
class SaveImageCryptIO(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="SaveImageCryptIO",
display_name="Save Image 🔒",
category="CryptIO🔒",
description="Encrypt and save images to the output directory",
inputs=[
io.Image.Input("images", tooltip="The images to encrypt and save"),
io.String.Input("filename_prefix", default="ComfyUI", tooltip="Prefix for the saved encrypted file"),
io.Combo.Input("format", options=["PNG", "JPG", "WEBP", "AVIF"], tooltip="The file format in which to save the image"),
io.Int.Input("quality", default=95, min=1, max=100, step=1, tooltip="Quality setting for the saved image"),
io.Boolean.Input("auto_download", default=False, tooltip="Automatically download decrypted image in browser"),
],
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
is_output_node=True,
)
@classmethod
def execute(cls, images, filename_prefix="ComfyUI", format="PNG", quality=95, auto_download=False):
output_dir = folder_paths.get_output_directory()
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
filename_prefix, output_dir, images[0].shape[1], images[0].shape[0]
)
prompt = cls.hidden.prompt
extra_pnginfo = cls.hidden.extra_pnginfo
results = []
for batch_number, image in enumerate(images):
i = 255.0 * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
encrypted_data, file_ext = _encode_and_encrypt(img, format, quality, 4, prompt, extra_pnginfo)
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
file = f"{filename_with_batch_num}_{counter:05}_.{file_ext}.encrypted"
encrypted_path = os.path.join(full_output_folder, file)
with open(encrypted_path, "wb") as f:
f.write(encrypted_data)
results.append({"filename": file, "subfolder": subfolder, "type": "output"})
counter += 1
return io.NodeOutput(ui={"images": results, "cryptio_images": results})
class PreviewImageCryptIO(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="PreviewImageCryptIO",
display_name="Preview Image 🔒",
category="CryptIO🔒",
description="Encrypt and preview images (saved to temp directory)",
inputs=[
io.Image.Input("images", tooltip="The images to encrypt and preview"),
io.Combo.Input("format", options=["PNG", "JPG", "WEBP", "AVIF"], tooltip="The file format in which to save the image"),
io.Int.Input("quality", default=95, min=1, max=100, step=1, tooltip="Quality setting for the saved image"),
io.Boolean.Input("auto_download", default=False, tooltip="Automatically download decrypted image in browser"),
],
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
is_output_node=True,
)
@classmethod
def execute(cls, images, auto_download=False, format="PNG", quality=95):
prefix = "_temp_" + "".join(random.choice("abcdefghijklmnopqrstuvwxyz") for _ in range(5))
output_dir = folder_paths.get_temp_directory()
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
prefix, output_dir, images[0].shape[1], images[0].shape[0]
)
prompt = cls.hidden.prompt
extra_pnginfo = cls.hidden.extra_pnginfo
results = []
for batch_number, image in enumerate(images):
i = 255.0 * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
encrypted_data, file_ext = _encode_and_encrypt(img, format, quality, 1, prompt, extra_pnginfo)
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
file = f"{filename_with_batch_num}_{counter:05}_.{file_ext}.encrypted"
encrypted_path = os.path.join(full_output_folder, file)
with open(encrypted_path, "wb") as f:
f.write(encrypted_data)
results.append({"filename": file, "subfolder": subfolder, "type": "temp"})
counter += 1
return io.NodeOutput(ui={"images": results, "cryptio_images": results})
-3
View File
@@ -2,7 +2,6 @@ import os
import random
from io import BytesIO
import folder_paths # pyright: ignore[reportMissingImports]
from .utils import _key_manager
import av
import torch
import numpy as np
@@ -183,8 +182,6 @@ class SaveVideoCryptIO(io.ComfyNode):
# Save video to in-memory buffer to avoid writing plaintext to disk
buffer = BytesIO()
# Save video to in-memory buffer using unified helper
buffer = BytesIO()
_save_to_bytesio_impl(video, buffer, metadata=metadata)
video_data = buffer.getvalue()
buffer.close()
+44 -58
View File
@@ -1,83 +1,69 @@
import base64
from comfy_api.latest import io
from .utils import _key_manager
from .utils.crypto_utils import decrypt_data_hybrid
class TextEncrypt:
"""
客户端加密节点,使用公钥加密文本
"""
def __init__(self):
pass
class TextEncrypt(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="TextEncrypt",
display_name="Text Encrypt 🔒",
category="CryptIO🔒",
description="Encrypt text with server public key (encryption performed in frontend JavaScript)",
inputs=[
io.String.Input("text", multiline=True, default="Text to encrypt", tooltip="Text to encrypt"),
io.Boolean.Input("encrypted", default=False, tooltip="Whether the text has been encrypted by the frontend"),
],
outputs=[
io.String.Output(display_name="text"),
io.String.Output(display_name="encrypted_text"),
],
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"multiline": True, "default": "要加密的文本"}),
"encrypted": ("BOOLEAN", {"default": False}),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("text", "encrypted_text")
FUNCTION = "crypt"
CATEGORY = "CryptIO🔒"
def crypt(self, text: str, encrypted):
"""使用公钥加密文本
在客户端JavaScript中实现,这里只是占位符
实际加密在前端JavaScript中完成
"""
def execute(cls, text, encrypted):
try:
if text.startswith("ENCRYPTED:"):
encrypted_bytes = base64.b64decode(text[10:])
# 使用混合解密
decrypted_data = decrypt_data_hybrid(encrypted_bytes, _key_manager.server_private_key)
decrypted_text = decrypted_data.decode("utf-8")
return (decrypted_text, text)
return io.NodeOutput(decrypted_text, text)
else:
raise ValueError("文本不是加密格式")
raise ValueError("Text is not in encrypted format")
except Exception as e:
print(f"解密错误: {e}")
return (f"解密错误: {str(e)}",)
print(f"Decryption error: {e}")
return io.NodeOutput(f"Decryption error: {str(e)}", text)
class TextDecrypt:
"""
服务端解密节点,使用私钥解密文本
"""
def __init__(self):
pass
class TextDecrypt(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="TextDecrypt",
display_name="Text Decrypt 🔒",
category="CryptIO🔒",
description="Decrypt text encrypted with client public key using server private key",
inputs=[
io.String.Input("encrypted_text", multiline=True, default="ENCRYPTED:...", tooltip="Encrypted text to decrypt"),
],
outputs=[
io.String.Output(display_name="decrypted_text"),
],
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"encrypted_text": ("STRING", {"multiline": True, "default": "ENCRYPTED:加密的文本"}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("decrypted_text",)
FUNCTION = "decrypt"
CATEGORY = "CryptIO🔒"
def decrypt(self, encrypted_text):
"""使用私钥解密文本(支持混合加密格式)"""
def execute(cls, encrypted_text):
try:
if encrypted_text.startswith("ENCRYPTED:"):
encrypted_bytes = base64.b64decode(encrypted_text[10:])
# 使用混合解密
decrypted_data = decrypt_data_hybrid(encrypted_bytes, _key_manager.server_private_key)
decrypted_text = decrypted_data.decode("utf-8")
return (decrypted_text,)
return io.NodeOutput(decrypted_text)
else:
return (encrypted_text,)
return io.NodeOutput(encrypted_text)
except Exception as e:
print(f"解密错误: {e}")
return (f"解密错误: {str(e)}",)
print(f"Decryption error: {e}")
return io.NodeOutput(f"Decryption error: {str(e)}")
+82 -102
View File
@@ -3,23 +3,19 @@ import hashlib
import numpy as np
import torch
from PIL import Image, ImageSequence, ImageOps
from .utils import _key_manager
from .utils.crypto_utils import decrypt_data_hybrid
from io import BytesIO
import folder_paths # pyright: ignore[reportMissingImports]
import node_helpers # pyright: ignore[reportMissingImports]
from comfy_api.latest import io
from .utils import _key_manager
from .utils.crypto_utils import decrypt_data_hybrid
class UploadImageCryptIO:
"""
加密图片上传节点,使用公钥在客户端加密图片数据,服务端使用私钥解密
"""
def __init__(self):
self.input_dir = folder_paths.get_input_directory()
self.type = "input"
class UploadImageCryptIO(io.ComfyNode):
@classmethod
def INPUT_TYPES(s):
def define_schema(cls):
input_dir = folder_paths.get_input_directory()
files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))]
encrypted_files = [
@@ -37,107 +33,91 @@ class UploadImageCryptIO:
]
all_files = sorted(set(encrypted_files))
return {
"required": {"image": (all_files, {"file_upload": True})},
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image", "mask")
FUNCTION = "load_image"
CATEGORY = "CryptIO🔒"
def load_image(self, image):
"""
加载并解密图片
"""
try:
# 检查是否是加密文件(文件名以 .encrypted 结尾)
if isinstance(image, str) and image.endswith(".encrypted"):
# 从加密文件读取
image_path = folder_paths.get_annotated_filepath(image)
with open(image_path, "rb") as f:
encrypted_combined = f.read()
# 使用服务端私钥解密(混合加密)
decrypted_data = decrypt_data_hybrid(encrypted_combined, _key_manager.server_private_key)
# 从字节数据创建图片
from io import BytesIO
img = Image.open(BytesIO(decrypted_data))
else:
return (None, None)
# 处理图片(与LoadImage节点相同的处理逻辑)
output_images = []
output_masks = []
w, h = None, None
for i in ImageSequence.Iterator(img):
i = node_helpers.pillow(ImageOps.exif_transpose, i)
if i.mode == "I":
i = i.point(lambda i: i * (1 / 255))
image_rgb = i.convert("RGB")
if len(output_images) == 0:
w = image_rgb.size[0]
h = image_rgb.size[1]
if image_rgb.size[0] != w or image_rgb.size[1] != h:
continue
image_array = np.array(image_rgb).astype(np.float32) / 255.0
image_tensor = torch.from_numpy(image_array)[None,]
# 处理遮罩
if "A" in i.getbands():
mask = np.array(i.getchannel("A")).astype(np.float32) / 255.0
mask = 1.0 - torch.from_numpy(mask)
elif i.mode == "P" and "transparency" in i.info:
mask = np.array(i.convert("RGBA").getchannel("A")).astype(np.float32) / 255.0
mask = 1.0 - torch.from_numpy(mask)
else:
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
output_images.append(image_tensor)
output_masks.append(mask.unsqueeze(0))
if len(output_images) > 1 and img.format not in ["MPO"]:
output_image = torch.cat(output_images, dim=0)
output_mask = torch.cat(output_masks, dim=0)
else:
output_image = output_images[0]
output_mask = output_masks[0]
return (output_image, output_mask)
except Exception as e:
print(f"加载/解密图片错误: {e}")
raise ValueError(f"Failed to decrypt image: {e}")
return io.Schema(
node_id="UploadImageCryptIO",
display_name="Upload Image 🔒",
category="CryptIO🔒",
description="Upload and decrypt image files encrypted with server public key",
inputs=[
io.Combo.Input("image", options=all_files, extra_dict={"image_upload": True}, tooltip="Select an encrypted image file to upload and decrypt"),
],
outputs=[
io.Image.Output(display_name="image"),
io.Mask.Output(display_name="mask"),
],
)
@classmethod
def IS_CHANGED(s, image):
"""检查图片是否已更改"""
def execute(cls, image):
if not isinstance(image, str) or not image.endswith(".encrypted"):
raise ValueError("Invalid image input, expected encrypted image file")
image_path = folder_paths.get_annotated_filepath(image)
with open(image_path, "rb") as f:
encrypted_combined = f.read()
decrypted_data = decrypt_data_hybrid(encrypted_combined, _key_manager.server_private_key)
img = Image.open(BytesIO(decrypted_data))
output_images = []
output_masks = []
w, h = None, None
for i in ImageSequence.Iterator(img):
i = node_helpers.pillow(ImageOps.exif_transpose, i)
if i.mode == "I":
i = i.point(lambda i: i * (1 / 255))
image_rgb = i.convert("RGB")
if len(output_images) == 0:
w = image_rgb.size[0]
h = image_rgb.size[1]
if image_rgb.size[0] != w or image_rgb.size[1] != h:
continue
image_array = np.array(image_rgb).astype(np.float32) / 255.0
image_tensor = torch.from_numpy(image_array)[None,]
if "A" in i.getbands():
mask = np.array(i.getchannel("A")).astype(np.float32) / 255.0
mask = 1.0 - torch.from_numpy(mask)
elif i.mode == "P" and "transparency" in i.info:
mask = np.array(i.convert("RGBA").getchannel("A")).astype(np.float32) / 255.0
mask = 1.0 - torch.from_numpy(mask)
else:
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
output_images.append(image_tensor)
output_masks.append(mask.unsqueeze(0))
if len(output_images) > 1 and img.format not in ["MPO"]:
output_image = torch.cat(output_images, dim=0)
output_mask = torch.cat(output_masks, dim=0)
else:
output_image = output_images[0]
output_mask = output_masks[0]
return io.NodeOutput(output_image, output_mask)
@classmethod
def fingerprint_inputs(cls, image):
if isinstance(image, str) and not image.startswith("ENCRYPTED:"):
image_path = folder_paths.get_annotated_filepath(image)
m = hashlib.sha256()
with open(image_path, "rb") as f:
m.update(f.read())
return m.digest().hex()
return float("NaN") # 对于加密数据,总是重新处理
return float("NaN")
@classmethod
def VALIDATE_INPUTS(s, image):
"""验证输入"""
def validate_inputs(cls, image):
if isinstance(image, str) and image.endswith(".encrypted"):
# 加密数据由解密逻辑处理
# 检查加密文件是否存在
if image.endswith(".encrypted"):
if not folder_paths.exists_annotated_filepath(image):
return f"Invalid encrypted image file: {image}"
if not folder_paths.exists_annotated_filepath(image):
return f"Invalid encrypted image file: {image}"
return True
if not folder_paths.exists_annotated_filepath(image):
+33 -56
View File
@@ -9,14 +9,22 @@ import type {
} from "@comfyorg/comfyui-frontend-types";
import type { CryptIONode, CryptIOApp } from "./types.js";
import { loadEncryptedImageFromParams, downloadDecryptedImage } from "./utils/imageLoader.js";
import { downloadDecryptedImage } from "./utils/imageLoader.js";
import { syncSWStatus } from "./utils/swSync.js";
const app: CryptIOApp = rawApp as any;
const api: ComfyApi = rawApi;
// 注册扩展
// ──────────────────────────────────────────────────
// Node extension
// ──────────────────────────────────────────────────
app.registerExtension({
name: "cryptio.SaveImageCryptIO",
async setup() {
await syncSWStatus();
},
async beforeRegisterNodeDef(
nodeType: any,
nodeData: ComfyNodeDef,
@@ -26,54 +34,31 @@ app.registerExtension({
// 覆盖默认的图片处理
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (this: CryptIONode, message: any) {
// 调用原始处理
// 调用原始处理 (populates this.imgs from message.images — SW解密透明)
if (onExecuted) {
onExecuted.apply(this, arguments);
}
// 处理加密图片
// Auto-download: fetch the /view URL directly, SW decrypts transparently
if (message?.cryptio_images) {
const autoDownload = this.widgets.find((n: any) => n.name === "auto_download")?.value;
// Revoke previous blob URLs to prevent memory leaks
if (this._cryptioBlobUrls) {
for (const url of this._cryptioBlobUrls) {
URL.revokeObjectURL(url);
}
}
this._cryptioBlobUrls = [] as string[];
for (let i = 0; i < message.cryptio_images.length; i++) {
const imageInfo = message.cryptio_images[i];
if (imageInfo.filename && imageInfo.filename.endsWith(".encrypted")) {
// 异步加载并解密图片
loadEncryptedImageFromParams(api, imageInfo)
.then((imageUrl) => {
if (this._cryptioBlobUrls) {
this._cryptioBlobUrls.push(imageUrl);
}
// 更新节点的图片显示
const img = new Image();
img.onload = () => {
if (!this.imgs) {
this.imgs = [];
}
this.imgs[i] = img;
if (app.rootGraph) {
app.rootGraph.setDirtyCanvas(true, false);
}
// 如果开启了自动下载
if (autoDownload) {
downloadDecryptedImage(imageUrl, imageInfo.filename);
console.log(`Auto-downloaded decrypted image: ${imageInfo.filename.replace(/\.encrypted$/, "")}`);
}
};
img.src = imageUrl;
if (autoDownload && imageInfo.filename && imageInfo.filename.endsWith(".encrypted")) {
const params = new URLSearchParams(imageInfo);
const url = api.apiURL(`/view?${params}`);
fetch(url)
.then((r) => r.blob())
.then((blob) => {
const downloadUrl = URL.createObjectURL(blob);
downloadDecryptedImage(downloadUrl, imageInfo.filename);
URL.revokeObjectURL(downloadUrl);
console.log(
"[CryptIO] Auto-downloaded:", imageInfo.filename.replace(/\.encrypted$/, "")
);
})
.catch((error) => {
console.error("Failed to decrypt image for preview:", error);
});
.catch((err) => console.error("[CryptIO] Auto-download failed:", err));
}
}
}
@@ -86,7 +71,6 @@ app.registerExtension({
getExtraMenuOptions.apply(this, arguments);
}
// 如果节点有图片
if (this.imgs && this.imgs.length > 0) {
options.push({
content: "下载解密图片",
@@ -94,10 +78,16 @@ app.registerExtension({
if (this.imgs) {
for (let i = 0; i < this.imgs.length; i++) {
const img = this.imgs[i];
// 获取原始文件名
const filename = this.images?.[i]?.filename || `image_${i}.png.encrypted`;
if (img.src) {
downloadDecryptedImage(img.src, filename);
fetch(img.src)
.then((r) => r.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
downloadDecryptedImage(url, filename);
URL.revokeObjectURL(url);
})
.catch((err) => console.error("[CryptIO] Download failed:", err));
}
}
}
@@ -105,19 +95,6 @@ app.registerExtension({
});
}
};
// Cleanup blob URLs on node removal
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function (this: CryptIONode) {
if (this._cryptioBlobUrls) {
for (const url of this._cryptioBlobUrls) {
URL.revokeObjectURL(url);
}
this._cryptioBlobUrls = [];
}
if (onRemoved) {
onRemoved.apply(this, arguments);
}
};
}
},
});
+5
View File
@@ -10,6 +10,7 @@ import {
CLIENT_KEYPAIR_STORAGE_KEY,
SERVER_KEYS_STORAGE_KEY,
} from "./utils/cryptoKeys.js";
import { syncSWStatus, clearSWKeys } from "./utils/swSync.js";
const app: ComfyApp = rawApp;
@@ -239,6 +240,7 @@ async function handleGenerate(): Promise<void> {
const { generateClientKeyPair } = await import("./utils/cryptoKeys.js");
const keyPair = await generateClientKeyPair();
localStorage.setItem(CLIENT_KEYPAIR_STORAGE_KEY, JSON.stringify(keyPair));
await syncSWStatus();
showToast("New client keys generated", "success");
} catch (error) {
showToast(`Failed to generate keys: ${error}`, "error");
@@ -254,6 +256,7 @@ async function handleClear(): Promise<void> {
localStorage.removeItem(CLIENT_KEYPAIR_STORAGE_KEY);
localStorage.removeItem(SERVER_KEYS_STORAGE_KEY);
clearSWKeys();
showToast("All keys cleared", "success");
}
@@ -284,6 +287,7 @@ async function handleUploadClient(event: Event): Promise<void> {
const data = await readJSONFile(file);
if (data.publicKey && data.privateKey) {
localStorage.setItem(CLIENT_KEYPAIR_STORAGE_KEY, JSON.stringify(data));
await syncSWStatus();
showToast("Client keys uploaded", "success");
} else {
showToast("Invalid key format — must contain publicKey and privateKey", "error");
@@ -300,6 +304,7 @@ async function handleUploadServer(event: Event): Promise<void> {
const data = await readJSONFile(file);
if (data.publicKey && data.privateKey) {
localStorage.setItem(SERVER_KEYS_STORAGE_KEY, JSON.stringify(data));
await syncSWStatus();
showToast("Server keys uploaded", "success");
} else {
showToast("Invalid key format — must contain publicKey and privateKey", "error");
+3 -5
View File
@@ -11,6 +11,7 @@ import type { CryptIONode, CryptIOApp } from "./types.js";
import { exchangeKeys } from "./utils/cryptoKeys.js";
import { handleFileUpload, createPreviewUpdateFunction } from "./utils/uploadUtils.js";
import { syncSWStatus } from "./utils/swSync.js";
const app: CryptIOApp = rawApp as any;
const api: ComfyApi = rawApi;
@@ -23,6 +24,7 @@ app.registerExtension({
try {
await exchangeKeys();
console.log("CryptIO: Keys exchanged successfully");
await syncSWStatus();
} catch (error) {
console.error("CryptIO: Failed to exchange keys:", error);
}
@@ -146,13 +148,9 @@ app.registerExtension({
return r;
};
// Cleanup blob URLs on node removal
// Cleanup on node removal
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function (this: CryptIONode) {
if (this._cryptioPreviewUrl) {
URL.revokeObjectURL(this._cryptioPreviewUrl);
this._cryptioPreviewUrl = null;
}
if (onRemoved) {
onRemoved.apply(this, arguments);
}
+16 -4
View File
@@ -10,8 +10,8 @@ import type {
import type { CryptIONode, VideoPreviewWidget, CryptIOApp } from "./types.js";
import { exchangeKeys } from "./utils/cryptoKeys.js";
import { loadEncryptedVideoFromFilename } from "./utils/videoLoader.js";
import { uploadEncryptedFile } from "./utils/uploadUtils.js";
import { syncSWStatus } from "./utils/swSync.js";
import {
createVideoWidget,
renderVideoInWidget,
@@ -22,11 +22,22 @@ const app: CryptIOApp = rawApp as any;
const api: ComfyApi = rawApi;
/**
* Load encrypted video from filename and render in widget
* Load encrypted video from filename and render in widget.
* SW intercepts /view?*.encrypted → decrypts transparently with server key.
*/
async function updateVideoWidget(widget: VideoPreviewWidget, filename: string) {
const videoUrl = await loadEncryptedVideoFromFilename(filename);
await renderVideoInWidget(widget, videoUrl);
const url = `/api/view?filename=${encodeURIComponent(filename)}&type=input`;
try {
await renderVideoInWidget(widget, url);
} catch {
console.warn("[CryptIO] Video preview load failed, syncing SW and retrying...");
try {
await syncSWStatus();
await renderVideoInWidget(widget, url);
} catch (err) {
console.error("[CryptIO] Video preview failed after sync:", filename, err);
}
}
}
/**
@@ -70,6 +81,7 @@ app.registerExtension({
try {
await exchangeKeys();
console.log("CryptIO: Keys exchanged successfully for video upload");
await syncSWStatus();
} catch (error) {
console.error("CryptIO: Failed to exchange keys:", error);
}
+323
View File
@@ -0,0 +1,323 @@
// @ts-nocheck
// CryptIO Service Worker — transparently decrypts *.encrypted files
// served via /view?filename=... so both Vue ImagePreview and legacy
// <img> / fetch() get plaintext without any frontend DOM hacks.
//
// Self-contained — no external imports (avoids SW path resolution issues).
const CRYPTO: Crypto = self.crypto as any;
const LOG = (...args: any[]) => console.log("[CryptIO-SW]", ...args);
/* ------------------------------------------------------------------ */
/* Base64 */
/* ------------------------------------------------------------------ */
function base64ToBytes(base64: string): Uint8Array {
const binary = self.atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
/* ------------------------------------------------------------------ */
/* PEM → CryptoKey (RSA-OAEP private key, pkcs8) */
/* ------------------------------------------------------------------ */
async function importPrivateKeyFromPem(pem: string): Promise<CryptoKey> {
const header = "-----BEGIN PRIVATE KEY-----";
const footer = "-----END PRIVATE KEY-----";
const contents = pem
.substring(pem.indexOf(header) + header.length, pem.indexOf(footer))
.replace(/\s/g, "");
const der = base64ToBytes(contents);
return CRYPTO.subtle.importKey("pkcs8", der, { name: "RSA-OAEP", hash: "SHA-256" }, false, ["decrypt"]);
}
/* ------------------------------------------------------------------ */
/* Hybrid decrypt: RSA-OAEP unwrap AES key → AES-GCM decrypt */
/* ------------------------------------------------------------------ */
async function decryptData(encrypted: Uint8Array, privateKeyPem: string): Promise<Uint8Array> {
// Wire format: [4B encKeyLen][encKey][4B ivLen][iv][ciphertext]
const view = new DataView(encrypted.buffer, encrypted.byteOffset, encrypted.byteLength);
let offset = 0;
const encKeyLen = view.getUint32(offset, false); offset += 4;
const encKey = encrypted.slice(offset, offset + encKeyLen); offset += encKeyLen;
const ivLen = view.getUint32(offset, false); offset += 4;
const iv = encrypted.slice(offset, offset + ivLen); offset += ivLen;
const ciphertext = encrypted.slice(offset);
const privateKey = await importPrivateKeyFromPem(privateKeyPem);
const aesKeyBuf = await CRYPTO.subtle.decrypt({ name: "RSA-OAEP" }, privateKey, encKey);
const aesKey = await CRYPTO.subtle.importKey("raw", aesKeyBuf, { name: "AES-GCM", length: 256 }, false, ["decrypt"]);
const plaintext = await CRYPTO.subtle.decrypt({ name: "AES-GCM", iv }, aesKey, ciphertext);
return new Uint8Array(plaintext);
}
/* ------------------------------------------------------------------ */
/* MIME helpers */
/* ------------------------------------------------------------------ */
function getRealExt(urlOrFilename: string): string {
// Extract filename from URL query if present
const url = new URL(urlOrFilename);
const filename = url.searchParams.get("filename") || url.pathname.split("/").pop() || urlOrFilename;
const parts = filename.split(".");
if (parts.length >= 2 && parts[parts.length - 1] === "encrypted") return parts[parts.length - 2];
return parts[parts.length - 1];
}
function getMimeType(ext: string): string {
const map: Record<string, string> = {
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
webp: "image/webp", avif: "image/avif", gif: "image/gif",
svg: "image/svg+xml", bmp: "image/bmp",
mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime",
};
return map[ext.toLowerCase()] || "application/octet-stream";
}
/* ------------------------------------------------------------------ */
/* SHA-256 fingerprint */
/* ------------------------------------------------------------------ */
async function sha256(str: string): Promise<string> {
const data = new TextEncoder().encode(str);
const hashBuffer = await CRYPTO.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
}
/* ------------------------------------------------------------------ */
/* IndexedDB persistence */
/* ------------------------------------------------------------------ */
const DB_NAME = "cryptio-sw-store";
const DB_VERSION = 1;
const STORE_NAME = "keys";
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
if (!req.result.objectStoreNames.contains(STORE_NAME)) {
req.result.createObjectStore(STORE_NAME);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function loadKeysFromDB(): Promise<{ client: string | null; clientFp: string | null; server: string | null; serverFp: string | null }> {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const store = tx.objectStore(STORE_NAME);
const getClient = store.get("clientKey");
const getClientFp = store.get("clientFingerprint");
const getServer = store.get("serverKey");
const getServerFp = store.get("serverFingerprint");
const result = { client: null as string | null, clientFp: null as string | null, server: null as string | null, serverFp: null as string | null };
getClient.onsuccess = () => { if (getClient.result) result.client = getClient.result; };
getClientFp.onsuccess = () => { if (getClientFp.result) result.clientFp = getClientFp.result; };
getServer.onsuccess = () => { if (getServer.result) result.server = getServer.result; };
getServerFp.onsuccess = () => { if (getServerFp.result) result.serverFp = getServerFp.result; };
tx.oncomplete = () => resolve(result);
tx.onerror = () => reject(tx.error);
});
} catch {
return { client: null, clientFp: null, server: null, serverFp: null };
}
}
async function saveKeyToDB(key: string, keyName: string, fp: string, fpName: string): Promise<void> {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
store.put(key, keyName);
store.put(fp, fpName);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch { /* ignore — IndexedDB unavailable */ }
}
async function clearKeysFromDB(): Promise<void> {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
store.clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch { /* ignore */ }
}
/* ------------------------------------------------------------------ */
/* State */
/* ------------------------------------------------------------------ */
let clientPrivateKeyPem: string | null = null;
let clientFingerprint: string | null = null;
let serverPrivateKeyPem: string | null = null;
let serverFingerprint: string | null = null;
function selectKey(url: string): string | null {
// type=input → uploaded images encrypted with SERVER public key → server private key only
// type=output / type=temp → save/preview encrypted with CLIENT public key → client private key
if (url.includes("type=input")) return serverPrivateKeyPem;
return clientPrivateKeyPem;
}
/* ------------------------------------------------------------------ */
/* Lifecycle */
/* ------------------------------------------------------------------ */
(self as any).addEventListener("install", (event: ExtendableEvent) => {
LOG("install — skipping waiting");
(self as any).skipWaiting();
});
(self as any).addEventListener("activate", (event: ExtendableEvent) => {
LOG("activate — loading keys from IndexedDB + claiming clients");
event.waitUntil(
(async () => {
try {
const stored = await loadKeysFromDB();
if (stored.client) {
clientPrivateKeyPem = stored.client;
clientFingerprint = stored.clientFp || null;
}
if (stored.server) {
serverPrivateKeyPem = stored.server;
serverFingerprint = stored.serverFp || null;
}
LOG("keys loaded from DB — client:", !!clientPrivateKeyPem, "server:", !!serverPrivateKeyPem);
} catch (err) {
LOG("loadKeysFromDB failed:", err);
}
try {
await (self as any).clients.claim();
const clients = await (self as any).clients.matchAll();
LOG("claim OK, clients in scope:", clients.length);
} catch (err) {
LOG("claim ERROR:", err);
}
})()
);
});
/* ------------------------------------------------------------------ */
/* Messages from main thread */
/* ------------------------------------------------------------------ */
self.addEventListener("message", (event: ExtendableEvent & { data?: any; ports?: MessagePort[] }) => {
const msg = event.data;
if (msg?.type === "cryptio-set-key") {
LOG("set-key received, fingerprint:", msg.fingerprint?.substring(0, 8) + "...");
clientPrivateKeyPem = msg.key as string;
clientFingerprint = msg.fingerprint || null;
saveKeyToDB(msg.key, "clientKey", msg.fingerprint || "", "clientFingerprint").catch(() => {});
if (event.ports[0]) {
event.ports[0].postMessage({ type: "cryptio-set-key-ack", ok: true });
}
}
if (msg?.type === "cryptio-set-server-key") {
LOG("set-server-key received, fingerprint:", msg.fingerprint?.substring(0, 8) + "...");
serverPrivateKeyPem = msg.key as string;
serverFingerprint = msg.fingerprint || null;
saveKeyToDB(msg.key, "serverKey", msg.fingerprint || "", "serverFingerprint").catch(() => {});
if (event.ports[0]) {
event.ports[0].postMessage({ type: "cryptio-set-server-key-ack", ok: true });
}
}
if (msg?.type === "cryptio-sync-status") {
const port = event.ports[0];
if (port) {
(async () => {
const cfp = clientPrivateKeyPem ? (clientFingerprint || await sha256(clientPrivateKeyPem)) : null;
const sfp = serverPrivateKeyPem ? (serverFingerprint || await sha256(serverPrivateKeyPem)) : null;
port.postMessage({
type: "cryptio-status",
ready: true,
interceptEnabled: true,
keys: {
client: { loaded: !!clientPrivateKeyPem, fingerprint: cfp },
server: { loaded: !!serverPrivateKeyPem, fingerprint: sfp },
},
lastError: (self as any).__lastError || null,
});
})().catch((err) => {
port.postMessage({ type: "cryptio-status", ready: false, interceptEnabled: false, keys: { client: { loaded: false, fingerprint: null }, server: { loaded: false, fingerprint: null } }, lastError: String(err) });
});
}
}
if (msg?.type === "cryptio-clear-keys") {
LOG("clear-keys received");
clientPrivateKeyPem = null;
clientFingerprint = null;
serverPrivateKeyPem = null;
serverFingerprint = null;
clearKeysFromDB().catch(() => {});
if (event.ports[0]) {
event.ports[0].postMessage({ type: "cryptio-clear-keys-ack", ok: true });
}
}
});
/* ------------------------------------------------------------------ */
/* Intercept /view?*.encrypted → decrypt transparently */
/* ------------------------------------------------------------------ */
self.addEventListener("fetch", (event: FetchEvent) => {
const url = event.request.url;
if (!url.includes("/view?") && !url.includes("/api/view?")) return;
if (!url.includes(".encrypted")) return;
LOG("MATCH:", url);
event.respondWith(
(async () => {
// Fetch original encrypted response (does not re-trigger SW)
const response = await fetch(event.request);
const key = selectKey(url);
if (!key) {
LOG("no matching key, passing through encrypted response");
return response;
}
try {
const clone = response.clone();
const encrypted = new Uint8Array(await clone.arrayBuffer());
LOG("decrypting", encrypted.length, "bytes...");
const decrypted = await decryptData(encrypted, key);
const ext = getRealExt(url);
const mime = getMimeType(ext);
LOG("decrypt OK —", decrypted.length, "bytes, mime:", mime);
return new Response(decrypted, {
status: 200,
headers: {
"Content-Type": mime,
"Cache-Control": "no-store",
"X-CryptIO-Decrypted": "1",
},
});
} catch (err) {
(self as any).__lastError = String(err);
LOG("decrypt failed:", err);
return response;
}
})()
);
});
LOG("initialised, waiting for sync...");
+13
View File
@@ -35,6 +35,19 @@ export interface CryptIONode {
[key: string]: any;
}
/**
* Service Worker status reported via sync message.
*/
export interface SWStatus {
ready: boolean;
interceptEnabled: boolean;
keys: {
client: { loaded: boolean; fingerprint: string | null };
server: { loaded: boolean; fingerprint: string | null };
};
lastError: string | null;
}
/**
* Custom properties for Video Preview widgets.
*/
+8 -2
View File
@@ -64,11 +64,17 @@ export async function loadEncryptedImageFromParams(api: ComfyApi, params: ImageL
}
// 直接读取二进制数据
let decryptedData: Uint8Array;
const encryptedArrayBuffer = await response.arrayBuffer();
const encryptedData = new Uint8Array(encryptedArrayBuffer);
// 使用客户端私钥解密(SaveImage/PreviewImage场景)
const decryptedData = await decryptFileWithClientKey(encryptedData);
// If the Service Worker already decrypted the response, skip re-decryption
if (response.headers.get("X-CryptIO-Decrypted") === "1") {
decryptedData = encryptedData;
} else {
// 使用客户端私钥解密(SaveImage/PreviewImage场景)
decryptedData = await decryptFileWithClientKey(encryptedData);
}
// 转换为Blob URL
const blob = new Blob([decryptedData as BlobPart], {
+199
View File
@@ -0,0 +1,199 @@
/**
* SW status sync module — single source of truth for Service Worker
* registration, key delivery, and consistency verification.
*
* Replacements from the old swKeySender.ts:
* sendKeysToServiceWorker() → syncSWStatus()
* (nothing) → clearSWKeys()
*/
import { getClientKeyPair, getServerKeysWithExchange } from "./cryptoKeys.js";
let _syncPromise: Promise<void> | null = null;
/* ------------------------------------------------------------------ */
/* SHA-256 fingerprint */
/* ------------------------------------------------------------------ */
export async function sha256(str: string): Promise<string> {
const data = new TextEncoder().encode(str);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
}
/* ------------------------------------------------------------------ */
/* Find / register the CryptIO Service Worker */
/* ------------------------------------------------------------------ */
async function ensureSWReady(): Promise<ServiceWorker | null> {
if (!("serviceWorker" in navigator)) return null;
const SW_PATH = "/cryptio-sw.js";
// Check if already registered
const regs = await navigator.serviceWorker.getRegistrations();
const existing = regs.find((r) => {
const script = r.active?.scriptURL || r.waiting?.scriptURL || r.installing?.scriptURL || "";
return script.endsWith(SW_PATH);
});
let registration: ServiceWorkerRegistration;
if (existing) {
registration = existing;
} else {
registration = await navigator.serviceWorker.register(SW_PATH, { scope: "/" });
console.log("[CryptIO] SW registered:", registration.active?.scriptURL || registration.installing?.scriptURL);
}
await navigator.serviceWorker.ready;
// Wait for the SW to become the controller
for (let i = 0; i < 20 && (!navigator.serviceWorker.controller || !navigator.serviceWorker.controller.scriptURL.includes("cryptio")); i++) {
await new Promise((r) => setTimeout(r, 150));
}
const controller = navigator.serviceWorker.controller;
if (!controller || !controller.scriptURL.includes("cryptio")) {
console.warn("[CryptIO] SW not controlling page after wait — controllerchange listener will retry");
return null;
}
console.log("[CryptIO] SW controlling:", controller.scriptURL);
return controller;
}
/* ------------------------------------------------------------------ */
/* Query SW status via MessageChannel */
/* ------------------------------------------------------------------ */
function querySWStatus(sw: ServiceWorker): Promise<any> {
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
const timeout = setTimeout(() => {
channel.port1.close();
reject(new Error("SW status query timed out"));
}, 5000);
channel.port1.onmessage = (event) => {
clearTimeout(timeout);
if (event.data?.type === "cryptio-status") {
resolve(event.data);
} else {
reject(new Error("Unexpected SW response: " + JSON.stringify(event.data)));
}
};
sw.postMessage({ type: "cryptio-sync-status" }, [channel.port2]);
});
}
/* ------------------------------------------------------------------ */
/* Send a single key to SW (fire-and-forget) */
/* ------------------------------------------------------------------ */
function sendKeyToSW(sw: ServiceWorker, type: string, key: string, fingerprint: string): void {
sw.postMessage({ type, key, fingerprint });
}
/* ------------------------------------------------------------------ */
/* syncSWStatus — ensure SW is ready and holds consistent keys */
/* ------------------------------------------------------------------ */
export async function syncSWStatus(): Promise<void> {
if (_syncPromise) return _syncPromise;
_syncPromise = _doSync();
try {
await _syncPromise;
} finally {
_syncPromise = null;
}
}
async function _doSync(): Promise<void> {
const sw = await ensureSWReady();
if (!sw) {
console.warn("[CryptIO] sync skipped — SW not available");
return;
}
// Read main-thread keys + fingerprints
const clientKP = await getClientKeyPair();
const serverKeys = await getServerKeysWithExchange();
const expectedClientFp = await sha256(clientKP.privateKey);
const expectedServerFp = await sha256(serverKeys.privateKey);
// Query SW current state
const status = await querySWStatus(sw);
console.log("[CryptIO] SW status — client:", status.keys.client, "server:", status.keys.server);
// Push keys that are missing or mismatched
let updated = false;
if (!status.keys.client.loaded || status.keys.client.fingerprint !== expectedClientFp) {
console.log("[CryptIO] pushing client key to SW...");
sendKeyToSW(sw, "cryptio-set-key", clientKP.privateKey, expectedClientFp);
updated = true;
}
if (!status.keys.server.loaded || status.keys.server.fingerprint !== expectedServerFp) {
console.log("[CryptIO] pushing server key to SW...");
sendKeyToSW(sw, "cryptio-set-server-key", serverKeys.privateKey, expectedServerFp);
updated = true;
}
if (updated) {
// Wait for SW to process keys, then verify
await new Promise((r) => setTimeout(r, 100));
const finalStatus = await querySWStatus(sw);
const clientOk = finalStatus.keys.client.loaded && finalStatus.keys.client.fingerprint === expectedClientFp;
const serverOk = finalStatus.keys.server.loaded && finalStatus.keys.server.fingerprint === expectedServerFp;
if (clientOk && serverOk) {
console.log("[CryptIO] sync verified — keys consistent");
} else {
console.warn("[CryptIO] sync verification mismatch — client:", clientOk, "server:", serverOk);
}
} else {
console.log("[CryptIO] sync skipped — keys already consistent");
}
}
/* ------------------------------------------------------------------ */
/* clearSWKeys — clear all keys from SW memory + IndexedDB */
/* ------------------------------------------------------------------ */
export async function clearSWKeys(): Promise<void> {
const sw = navigator.serviceWorker?.controller;
if (!sw || !sw.scriptURL.includes("cryptio")) {
console.warn("[CryptIO] clearSWKeys skipped — no cryptio SW controlling");
return;
}
return new Promise((resolve) => {
const channel = new MessageChannel();
const timeout = setTimeout(() => {
channel.port1.close();
console.warn("[CryptIO] clearSWKeys timed out");
resolve();
}, 3000);
channel.port1.onmessage = () => {
clearTimeout(timeout);
console.log("[CryptIO] SW keys cleared");
resolve();
};
sw.postMessage({ type: "cryptio-clear-keys" }, [channel.port2]);
});
}
/* ------------------------------------------------------------------ */
/* sendKeysToServiceWorker — backward-compat wrapper */
/* ------------------------------------------------------------------ */
export async function sendKeysToServiceWorker(): Promise<void> {
await syncSWStatus();
}
/* ------------------------------------------------------------------ */
/* Controller change listener (set once at module load) */
/* ------------------------------------------------------------------ */
let _listenerSetup = false;
if (!_listenerSetup && typeof navigator !== "undefined" && "serviceWorker" in navigator) {
navigator.serviceWorker.addEventListener("controllerchange", () => {
console.log("[CryptIO] controllerchange — re-syncing status");
_syncPromise = null;
syncSWStatus().catch((err) => console.warn("[CryptIO] controllerchange sync failed:", err));
});
_listenerSetup = true;
}
+22 -22
View File
@@ -6,7 +6,7 @@
import type { ComfyApp } from "@comfyorg/comfyui-frontend-types";
import type { CryptIONode } from "../types.js";
import { encryptFileWithServerKey } from "./cryptoUtils.js";
import { loadEncryptedImageFromFilename } from "./imageLoader.js";
import { syncSWStatus } from "./swSync.js";
/**
* Upload an encrypted file to the server (encrypted with server public key).
@@ -68,8 +68,6 @@ export async function handleFileUpload(node: CryptIONode, file: File, app: Comfy
if (node.updatePreview) {
await node.updatePreview(result.name);
}
app.rootGraph?.setDirtyCanvas(true, false);
} catch (error) {
console.error("Upload error:", error);
alert("Failed to upload encrypted image: " + error);
@@ -83,28 +81,30 @@ export async function handleFileUpload(node: CryptIONode, file: File, app: Comfy
*/
export function createPreviewUpdateFunction(app: ComfyApp) {
return async function (this: CryptIONode, filename: string) {
if (!filename || !filename.endsWith(".encrypted")) {
return;
}
if (!filename || !filename.endsWith(".encrypted")) return;
const url = `/api/view?filename=${encodeURIComponent(filename)}&type=input`;
const loadImage = (): Promise<HTMLImageElement> =>
new Promise((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error(`Failed to load: ${filename}`));
i.src = url;
});
try {
// Revoke previous blob URL to prevent memory leak
if (this._cryptioPreviewUrl) {
URL.revokeObjectURL(this._cryptioPreviewUrl);
}
const imageUrl = await loadEncryptedImageFromFilename(filename);
this._cryptioPreviewUrl = imageUrl;
// 更新节点的图片显示
const img = new Image();
img.onload = () => {
this.imgs = [img];
this.imgs = [await loadImage()];
app.rootGraph?.setDirtyCanvas(true, false);
} catch {
console.warn("[CryptIO] Preview load failed, syncing SW status and retrying...");
try {
await syncSWStatus();
this.imgs = [await loadImage()];
app.rootGraph?.setDirtyCanvas(true, false);
};
img.src = imageUrl;
} catch (error) {
console.error("Preview error:", error);
} catch (err) {
console.error("[CryptIO] Preview failed after sync:", filename, err);
}
}
};
}
+8 -2
View File
@@ -64,11 +64,17 @@ export async function loadEncryptedVideoFromParams(api: ComfyApi, params: VideoL
}
// 直接读取二进制数据
let decryptedData: Uint8Array;
const encryptedArrayBuffer = await response.arrayBuffer();
const encryptedData = new Uint8Array(encryptedArrayBuffer);
// 使用客户端私钥解密(SaveVideo/PreviewVideo场景)
const decryptedData = await decryptFileWithClientKey(encryptedData);
// If the Service Worker already decrypted the response, skip re-decryption
if (response.headers.get("X-CryptIO-Decrypted") === "1") {
decryptedData = encryptedData;
} else {
// 使用客户端私钥解密(SaveVideo/PreviewVideo场景)
decryptedData = await decryptFileWithClientKey(encryptedData);
}
// 转换为Blob URL
const blob = new Blob([new Uint8Array(decryptedData)], {