Files
Doudou d7b96ecd4d ComfyUI RigShare 1.0.0
Multi-user collaboration for the current ComfyUI frontend, continued
from ComfyUI-Nexus by daxcay (MIT).

- Login page before ComfyUI loads, first-run admin setup, JWT sessions
- Personal API keys; server-side permission checks (edit, queue, manager, admin)
- Every workflow shared as a live room, with snapshots and history
- Per-workflow access lists (view / edit per person)
- Conflict-free concurrent edits (id remapping, per-id link merges)
- Presence: who is online, cursors, selections, follow mode
- Chat with unread badge, blinking icon and sound
- Server monitor (CPU, RAM, GPUs, queue) with a floating widget
- Admin options to hide the Comfy.org login and API templates
- Docs, changelog, screenshots and icon
2026-09-25 13:24:09 +00:00

274 lines
10 KiB
JavaScript

// RigShare — multi-user collaboration for the modern ComfyUI frontend.
// Continued from ComfyUI-Nexus by daxcay (MIT).
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { RigClient } from "./client.js";
import { RoomSync } from "./sync.js";
import { Presence } from "./presence.js";
import { RigSharePanel } from "./panel.js";
import { STYLES } from "./styles.js";
const TAB_ID = "rigshare";
const ICON_CLASS = "rigshare-tab-icon";
function setting(id, fallback) {
try {
const v = app.extensionManager?.setting?.get(id);
return v === undefined ? fallback : v;
} catch {
return fallback;
}
}
function installAuthHeaders(client) {
const original = api.fetchApi.bind(api);
api.fetchApi = (route, options = {}) => {
let headers = options.headers || {};
if (headers instanceof Headers) headers = Object.fromEntries(headers.entries());
return original(route, { ...options, headers: { ...headers, ...client.authHeaders() } });
};
}
// ComfyUI Manager: hide its button and refuse its commands without the
// "manager" permission (the server blocks its API too).
const MANAGER_COMMANDS = ["Comfy.Manager.Menu.ToggleVisibility", "Comfy.Manager.CustomNodesManager.ToggleVisibility"];
function installManagerGuard(client) {
const apply = () => {
const allowed = !client.online || !!client.perms.manager;
document.body.classList.toggle("rs-no-manager", !allowed);
for (const cmd of app.extensionManager?.command?.commands ?? []) {
if (!MANAGER_COMMANDS.includes(cmd.id) || cmd.__rigshare) continue;
const original = cmd.function;
cmd.function = (...args) => {
if (client.online && !client.perms.manager) {
client.emit("toast", { severity: "warn", summary: "RigShare", detail: "You need the Manager permission to open ComfyUI Manager. Ask an admin." });
return;
}
return original(...args);
};
cmd.__rigshare = true;
}
};
client.on("self", apply);
client.on("status", apply);
setTimeout(apply, 2000); // Manager registers its commands during setup
}
// Comfy.org account: hide the top-bar sign-in button and refuse the sign-in
// dialogs when the admin turned it off.
const SIGNIN_DIALOGS = ["global-signin", "api-nodes-signin"];
function installComfyAccountGuard(client) {
const hidden = () => !!client.server?.hide_comfy_account;
const patchStores = () => {
const cmd = (app.extensionManager?.command?.commands ?? []).find((c) => c.id === "Comfy.User.OpenSignInDialog");
if (cmd && !cmd.__rigshare) {
const original = cmd.function;
cmd.function = (...args) => (hidden() ? undefined : original(...args));
cmd.__rigshare = true;
}
try {
const vueApp = document.querySelector("[data-v-app]")?.__vue_app__;
const dialogs = vueApp?.config?.globalProperties?.$pinia?._s?.get("dialog");
if (dialogs && !dialogs.__rigshare) {
const show = dialogs.showDialog;
dialogs.showDialog = (options, ...rest) => {
if (hidden() && SIGNIN_DIALOGS.includes(options?.key)) return undefined;
return show.call(dialogs, options, ...rest);
};
dialogs.__rigshare = true;
}
} catch (e) {
console.warn("[RigShare] could not hook dialogs", e);
}
};
const apply = () => {
document.body.classList.toggle("rs-hide-comfy-account", hidden());
patchStores();
};
client.on("server", apply);
setTimeout(apply, 2000);
}
function installQueueGuard(client) {
const original = api.queuePrompt.bind(api);
api.queuePrompt = async (...args) => {
if (client.online && !client.perms.queue) {
throw new Error("RigShare: you do not have permission to queue prompts. Ask an admin, or log in with an account that can queue.");
}
return original(...args);
};
}
// Short two-tone "ping" synthesized with WebAudio (no asset to ship).
let audio = null;
function ping() {
try {
audio ??= new AudioContext();
if (audio.state === "suspended") audio.resume();
const now = audio.currentTime;
for (const [i, freq] of [880, 1320].entries()) {
const osc = audio.createOscillator();
const gain = audio.createGain();
osc.type = "sine";
osc.frequency.value = freq;
const t = now + i * 0.11;
gain.gain.setValueAtTime(0.0001, t);
gain.gain.exponentialRampToValueAtTime(0.18, t + 0.015);
gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.22);
osc.connect(gain).connect(audio.destination);
osc.start(t);
osc.stop(t + 0.25);
}
} catch { /* audio unavailable */ }
}
function tabButton() {
return document.querySelector(`.${ICON_CLASS}`)?.closest("button");
}
function setBlink(on) {
tabButton()?.classList.toggle("rigshare-blink", on);
}
// Remember the active palette so the login page (served before ComfyUI
// loads) can use the same colors.
const THEME_VARS = ["--bg-color", "--comfy-menu-bg", "--comfy-input-bg", "--fg-color", "--descrip-text",
"--border-color", "--p-primary-color", "--p-primary-contrast-color"];
let lastTheme = "";
function saveTheme() {
const css = getComputedStyle(document.documentElement);
const theme = Object.fromEntries(THEME_VARS.map((v) => [v, css.getPropertyValue(v).trim()]).filter(([, v]) => v));
const json = JSON.stringify(theme);
if (json === lastTheme) return;
lastTheme = json;
try { localStorage.setItem("rigshare.theme", json); } catch { /* ignore */ }
}
let rig = null;
app.registerExtension({
name: "Comfy.RigShare",
settings: [
{
id: "RigShare.ShareSaved",
category: ["RigShare", "Sharing", "ShareSaved"],
name: "Share saved workflows automatically (everyone opening the same file edits it together)",
type: "boolean",
defaultValue: true,
onChange: (v) => { if (rig) rig.sync.autoShareFiles = v; },
},
{
id: "RigShare.ShareUnsaved",
category: ["RigShare", "Sharing", "ShareUnsaved"],
name: "Share unsaved tabs automatically once they contain nodes",
type: "boolean",
defaultValue: true,
onChange: (v) => { if (rig) rig.sync.autoShareUnsaved = v; },
},
{
id: "RigShare.ShowCursors",
category: ["RigShare", "Presence", "ShowCursors"],
name: "Show other people's cursors and selections",
type: "boolean",
defaultValue: true,
onChange: (v) => { if (rig) { rig.presence.showCursors = v; rig.presence.redraw(); } },
},
{
id: "RigShare.ShowCursorNames",
category: ["RigShare", "Presence", "ShowCursorNames"],
name: "Show names next to cursors",
type: "boolean",
defaultValue: true,
onChange: (v) => { if (rig) { rig.presence.showNames = v; rig.presence.redraw(); } },
},
{
id: "RigShare.ChatSound",
category: ["RigShare", "Chat", "ChatSound"],
name: "Play a sound for new chat messages when the panel is closed",
type: "boolean",
defaultValue: true,
},
{
id: "RigShare.ChatToasts",
category: ["RigShare", "Chat", "ChatToasts"],
name: "Show a notification for new chat messages when the panel is closed",
type: "boolean",
defaultValue: false,
onChange: (v) => { if (rig) rig.panel.chatToasts = v; },
},
],
commands: [
{
id: "RigShare.OpenChat",
label: "RigShare: Open chat",
icon: "pi pi-comments",
function: async () => {
if (!rig?.panel.visible) await app.extensionManager.command.execute(`Workspace.ToggleSidebarTab.${TAB_ID}`);
rig?.panel.focusChat();
},
},
{
id: "RigShare.ShareTab",
label: "RigShare: Share this tab",
icon: "pi pi-share-alt",
function: () => rig?.sync.shareCurrent(),
},
],
async setup() {
const style = document.createElement("style");
style.textContent = STYLES;
document.head.append(style);
const client = new RigClient();
const sync = new RoomSync(app, api, client);
const presence = new Presence(app, client, sync);
const panel = new RigSharePanel(app, client, sync, presence);
rig = { client, sync, presence, panel };
window.rigshare = rig; // handy for debugging from the console
sync.autoShareFiles = setting("RigShare.ShareSaved", true);
sync.autoShareUnsaved = setting("RigShare.ShareUnsaved", true);
presence.showCursors = setting("RigShare.ShowCursors", true);
presence.showNames = setting("RigShare.ShowCursorNames", true);
panel.chatToasts = setting("RigShare.ChatToasts", false);
client.on("toast", (t) => app.extensionManager?.toast?.add({ life: 4000, ...t }));
installAuthHeaders(client);
installQueueGuard(client);
installManagerGuard(client);
installComfyAccountGuard(client);
presence.install();
app.extensionManager.registerSidebarTab({
id: TAB_ID,
icon: `pi pi-users ${ICON_CLASS}`,
title: "RigShare",
tooltip: "RigShare — collaboration",
label: "RigShare",
type: "custom",
iconBadge: null,
render: (el) => panel.mount(el),
destroy: () => panel.unmount(),
});
// The registered tab object is wrapped by Vue; mutating it through the
// store keeps the unread badge reactive.
panel.onBadge = (badge) => {
const tab = app.extensionManager.getSidebarTabs?.().find((t) => t.id === TAB_ID);
if (tab) tab.iconBadge = badge;
setBlink(!!badge);
};
panel.onNotify = () => {
if (setting("RigShare.ChatSound", true)) ping();
};
client.connect();
saveTheme();
setInterval(saveTheme, 5000);
},
});