From 51c16fa93e6dc24d65bc4bd38fa551509961a968 Mon Sep 17 00:00:00 2001 From: Edouard Durand Date: Sat, 26 Sep 2026 03:43:46 +0000 Subject: [PATCH] App mode support; hide ComfyUI's Workflows and Apps tabs Live edits keep each person's graph/App view, cursors are off in the App view, and .app.json workflows show as apps in Files, the Live list and Save. ComfyUI's own file tabs duplicate Files, so they are hidden by default (admin setting hide_comfy_file_tabs) and their shortcuts open Files. --- CHANGELOG.md | 7 +++++++ docs/files.md | 4 ++++ docs/settings.md | 1 + docs/sharing.md | 2 ++ rigshare_core/hub.py | 6 ++++-- rigshare_core/store.py | 2 ++ rigshare_core/workspace.py | 5 ++++- tests/python/test_server.py | 27 +++++++++++++++++++++++++ web/rigshare/files.js | 4 ++-- web/rigshare/main.js | 40 +++++++++++++++++++++++++++++++++++++ web/rigshare/panel.js | 5 ++++- web/rigshare/presence.js | 6 +++--- web/rigshare/save-dialog.js | 6 ++++-- web/rigshare/sync.js | 12 ++++++++++- 14 files changed, 115 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da7e6e8..1c765c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Added + +- App mode: live workflows work in ComfyUI's App view. Each person keeps their own graph or App view through live edits, and App inputs sync. Cursors are off in the App view. Workflows saved as Apps (`.app.json`) show with an app icon in Files and in the Live list, and Save keeps them Apps +- ComfyUI's own Workflows and Apps sidebar tabs are hidden, since Files replaces them, and their shortcuts open Files. Admin setting `hide_comfy_file_tabs`, on by default + ## 1.1.0 (2026-09-26) Workflows now live in folders, like a shared drive, and sharing follows them. diff --git a/docs/files.md b/docs/files.md index 09a5531..7f12903 100644 --- a/docs/files.md +++ b/docs/files.md @@ -29,6 +29,10 @@ RigShare organises ComfyUI's workflows folder like a shared drive. Where a workf +ComfyUI's own **Workflows** and **Apps** tabs are hidden, since Files replaces them, and their keyboard shortcuts open Files. Admins can bring them back under *Admin → Server settings*. Hiding them isn't what protects the files, though: the server applies the same rules whichever browser is used. If you show ComfyUI's open workflow tabs in the sidebar (*Comfy.Workflow.WorkflowTabsPosition*), the Workflows tab stays, because it lists them. + +Workflows saved as **Apps** (ComfyUI's App mode, `.app.json`) show with an app icon. Opening one opens it as an app. + Click a workflow to open it. The Files tab marks live workflows with a green dot. **From computer** opens a workflow file from your machine, like ComfyUI's own *Open*. Hover a row for its actions: **Rename**, **Move to…**, **Delete**, and on a shared folder you own, 🔒 **Who can open it**. The folder button creates a folder. In the Shared tab it creates a shared folder that you own. diff --git a/docs/settings.md b/docs/settings.md index cd75680..8ad93aa 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -27,6 +27,7 @@ Admins change these under **Admin → Server settings**. They are stored in `rig | `guest_permissions` | `{"edit": false, "queue": false}` | What guests may do | | `broadcast_execution` | `true` | Send progress and previews to everyone, not only whoever queued | | `hide_comfy_account` | `false` | Hide ComfyUI's Comfy.org sign-in button and dialog | +| `hide_comfy_file_tabs` | `true` | Hide ComfyUI's own Workflows and Apps sidebar tabs; RigShare's Files tab replaces them and their shortcuts open it | | `hide_api_templates` | `false` | Remove templates that need paid API nodes from the template browser | | `api_protection.enabled` | `true` | Server-side checks on queue, interrupt, free and upload calls | | `api_protection.trusted_ips` | `["127.0.0.1", "::1"]` | Addresses or networks that skip login and checks | diff --git a/docs/sharing.md b/docs/sharing.md index 56fe623..d218b75 100644 --- a/docs/sharing.md +++ b/docs/sharing.md @@ -29,6 +29,8 @@ Only the tab on screen is live. Switch to another tab and you leave the room; sw - Two people editing the same node at once: the last change wins. - Two people adding or connecting nodes at the same moment both keep their work. If both picked the same node or link id, the server renumbers the later one and quietly resyncs that person. Links and groups merge one by one, so a connection made by one person never erases another's. +**App mode.** Each person chooses for themselves whether a workflow shows as the node graph or as an App. Switching doesn't change anyone else's view, and live edits keep it. Inputs changed in the App view sync like any other widget value. The app's layout, meaning its chosen inputs and outputs, belongs to the workflow and syncs. Cursors aren't shown in the App view. + Viewers (no *Edit* permission) see a read-only canvas. Anything they change locally is put back. ## When a workflow stops being live diff --git a/rigshare_core/hub.py b/rigshare_core/hub.py index ffdf1e1..8add954 100644 --- a/rigshare_core/hub.py +++ b/rigshare_core/hub.py @@ -283,7 +283,8 @@ class Hub: out.append({"key": room.key, "name": room.name, "kind": room.kind, "version": room.version, "updated": int(room.updated), "members": members, "nodes": len((room.doc or {}).get("nodes") or []), - "restricted": self.folder_restricted(self.folder_of(room))}) + "restricted": self.folder_restricted(self.folder_of(room)), + "app": room.key.endswith(".app.json")}) out.sort(key=lambda r: (-len(r["members"]), -r["updated"])) return out @@ -318,7 +319,7 @@ class Hub: room.key = new_prefix + key[len(old_prefix):] if not is_live_key(room.key): continue # moved into a private folder: no longer live - room.name = room.key.rsplit("/", 1)[-1].removesuffix(".json") + room.name = room.key.rsplit("/", 1)[-1].removesuffix(".json").removesuffix(".app") room.dirty = True self.rooms[room.key] = room moved.append(room.key) @@ -373,6 +374,7 @@ class Hub: cfg = self.store.config return {"name": cfg.get("server_name"), "allow_guests": cfg.get("allow_guests"), "hide_comfy_account": bool(cfg.get("hide_comfy_account")), + "hide_comfy_file_tabs": bool(cfg.get("hide_comfy_file_tabs", True)), "hide_api_templates": bool(cfg.get("hide_api_templates"))} async def broadcast_server(self): diff --git a/rigshare_core/store.py b/rigshare_core/store.py index 7fb145a..cd44a18 100644 --- a/rigshare_core/store.py +++ b/rigshare_core/store.py @@ -47,6 +47,8 @@ DEFAULT_CONFIG = { }, # Hide ComfyUI's own Comfy.org account sign-in (top-bar button and dialog). "hide_comfy_account": False, + # ComfyUI's own Workflows and Apps sidebar tabs duplicate RigShare's Files tab. + "hide_comfy_file_tabs": True, # Remove templates that need paid API nodes from the template browser. "hide_api_templates": False, # Rooms nobody opened for this many days are forgotten. diff --git a/rigshare_core/workspace.py b/rigshare_core/workspace.py index e8fa88e..2d4ef1d 100644 --- a/rigshare_core/workspace.py +++ b/rigshare_core/workspace.py @@ -193,8 +193,9 @@ class Workspace: def _entry(self, rel, name, is_dir, kind, key, perms): full = self._abs(rel) + is_app = not is_dir and name.endswith(".app.json") # saved as an App (ComfyUI App mode) entry = {"type": "folder" if is_dir else "file", "path": rel, - "name": name if is_dir else name[:-5], + "name": name if is_dir else name[:-9] if is_app else name[:-5], "modified": int(os.path.getmtime(full)) if os.path.exists(full) else 0, "role": self.role(rel, kind, key, perms)} area, folder = self.area(rel) @@ -202,6 +203,8 @@ class Workspace: meta = self.folders.get(folder) or {} entry.update(owner=meta.get("owner"), restricted=bool(meta.get("acl")), manage=self.can_manage(folder, kind, key, perms)) + if is_app: + entry["app"] = True entry["deletable"] = self.can_delete(rel, kind, key, perms) return entry diff --git a/tests/python/test_server.py b/tests/python/test_server.py index 2197b6c..ea45315 100644 --- a/tests/python/test_server.py +++ b/tests/python/test_server.py @@ -324,3 +324,30 @@ def test_chat_history_pages(run): assert "hello 0" in texts and seqs == list(range(len(seqs))), "no gaps, no repeats" assert (await session.get(rig.url("/rigshare/api/chat?before=x"))).status in (400, 401) run(scenario()) + + +def test_apps_and_comfy_file_tabs(run): + async def scenario(): + async with rig_server() as rig: + admin = await rig.setup_admin() + alice = await rig.add_user("alice", edit=True) + h = rig.http + await h.post(rig.url(f"/api/userdata/{enc('workflows/users/alice/demo.app.json')}"), data=b"{}", headers=alice) + await h.post(rig.url(f"/api/userdata/{enc('workflows/users/alice/plain.json')}"), data=b"{}", headers=alice) + listing = await (await h.get(rig.url("/rigshare/api/workspace/list?path=users/alice"), headers=alice)).json() + entries = {e["name"]: e for e in listing["entries"]} + assert entries["demo"].get("app") is True and "app" not in entries["plain"], "apps named without .app" + + sock = await rig.ws("alice") + key = "file:workflows/shared/demo.app.json" + await sock.send({"type": "join", "room": key, "name": "demo", "doc": BASE_DOC}) + await sock.until("room") + presence = [m for m in await sock.drain() if m["type"] == "presence"][-1] + assert presence["rooms"][0]["app"] is True + + # ComfyUI's own Workflows/Apps tabs are hidden unless an admin turns that off. + assert rig.hub.server_info()["hide_comfy_file_tabs"] is True + await h.patch(rig.url("/rigshare/api/config"), json={"hide_comfy_file_tabs": False}, headers=admin) + assert (await sock.until("server"))["server"]["hide_comfy_file_tabs"] is False + await sock.close() + run(scenario()) diff --git a/web/rigshare/files.js b/web/rigshare/files.js index 270409b..55413ae 100644 --- a/web/rigshare/files.js +++ b/web/rigshare/files.js @@ -191,8 +191,8 @@ export class FilesBrowser { const owner = e.owner ? this.client.users.find((u) => u.key === e.owner)?.name ?? `@${e.owner}` : null; const meta = isFolder ? [e.owner !== undefined && owner ? `owner ${owner}` : null, e.role === "view" ? "view only" : null].filter(Boolean).join(" · ") - : [timeAgo(e.modified * 1000), e.role === "view" ? "view only" : null].filter(Boolean).join(" · "); - const icon = isFolder ? (e.restricted ? "pi-lock" : "pi-folder") : "pi-file"; + : [e.app ? "App" : null, timeAgo(e.modified * 1000), e.role === "view" ? "view only" : null].filter(Boolean).join(" · "); + const icon = isFolder ? (e.restricted ? "pi-lock" : "pi-folder") : e.app ? "pi-th-large" : "pi-file"; // Files outside private folders are live while open: show who is in them. const live = isFolder ? null : this.client.rooms?.find((r) => r.key === `file:workflows/${e.path}`); const disabled = this.mode === "pick" && !isFolder; diff --git a/web/rigshare/main.js b/web/rigshare/main.js index 502f50d..8da428a 100644 --- a/web/rigshare/main.js +++ b/web/rigshare/main.js @@ -93,6 +93,45 @@ function installComfyAccountGuard(client) { setTimeout(apply, 2000); } +// ComfyUI's Workflows and Apps sidebar tabs browse the same files as RigShare's +// Files tab, without its folders. The server enforces permissions either way; +// hiding them avoids two file browsers and buttons the server would refuse. +const COMFY_FILE_TABS = ["workflows", "apps"]; +function installFileTabsGuard(client) { + const removed = new Map(); + const manager = () => app.extensionManager; + const tabs = () => manager()?.getSidebarTabs?.() ?? manager()?.sidebarTab?.sidebarTabs ?? []; + const hidden = () => client.server?.hide_comfy_file_tabs !== false; + const openFiles = () => manager()?.command?.execute("Workspace.ToggleSidebarTab.rigshare-files"); + const apply = () => { + if (!client.server) return; // wait for the server's settings + const hide = hidden(); + for (const id of COMFY_FILE_TABS) { + const tab = tabs().find((t) => t.id === id); + // Open workflow tabs can be shown in the Workflows sidebar tab: keep it then. + const needed = id === "workflows" && setting("Comfy.Workflow.WorkflowTabsPosition", "Topbar") === "Sidebar"; + if (hide && tab && !needed) { + removed.set(id, tab); + (manager().unregisterSidebarTab ?? manager().sidebarTab.unregisterSidebarTab)(id); + } else if ((!hide || needed) && !tab && removed.has(id)) { + manager().registerSidebarTab(removed.get(id)); + removed.delete(id); + } + // Their keyboard shortcuts open Files instead. + const cmd = (manager()?.command?.commands ?? []).find((c) => c.id === `Workspace.ToggleSidebarTab.${id}`); + if (cmd && !cmd.__rigshare) { + const original = cmd.function; + cmd.function = (...args) => (removed.has(id) ? openFiles() : original(...args)); + cmd.__rigshare = true; + } + } + }; + client.on("server", apply); + // ComfyUI registers its own tabs around the time extensions load. + let tries = 0; + const timer = setInterval(() => { apply(); if (++tries > 15) clearInterval(timer); }, 1000); +} + function installQueueGuard(client) { const original = api.queuePrompt.bind(api); api.queuePrompt = async (...args) => { @@ -235,6 +274,7 @@ app.registerExtension({ installQueueGuard(client); installManagerGuard(client); installComfyAccountGuard(client); + installFileTabsGuard(client); installSaveDialog(app, api, client, sync); presence.install(); diff --git a/web/rigshare/panel.js b/web/rigshare/panel.js index e20833f..e2e7cdf 100644 --- a/web/rigshare/panel.js +++ b/web/rigshare/panel.js @@ -392,7 +392,7 @@ export class RigSharePanel { const members = r.members.map((id) => byId.get(id)).filter(Boolean); const folder = r.key.replace(/^file:workflows\//, "").split("/").slice(0, -1).join("/"); return h("div", { class: `rs-row-item rs-room ${here ? "here" : ""}`, title: r.key.replace(/^file:workflows\//, "") }, - h("i", { class: "pi pi-file rs-row-icon" }), + h("i", { class: `pi ${r.app ? "pi-th-large" : "pi-file"} rs-row-icon`, title: r.app ? "App" : null }), h("div", { class: "rs-grow rs-min0" }, h("div", { class: "rs-name rs-ellipsis" }, r.name, r.restricted ? h("i", { class: "pi pi-lock rs-inline-icon rs-muted", title: "Restricted" }) : null), h("div", { class: "rs-muted rs-small rs-ellipsis" }, folder ? folder.replace(/^shared(\/|$)/, "Shared$1") : "Workflows", ` · ${r.nodes} nodes`), @@ -777,6 +777,7 @@ export class RigSharePanel { broadcast: box(cfg.broadcast_execution), protect: box(cfg.api_protection?.enabled), hideAccount: box(cfg.hide_comfy_account), + hideFileTabs: box(cfg.hide_comfy_file_tabs ?? true), hideApiTemplates: box(cfg.hide_api_templates), trusted: text((cfg.api_protection?.trusted_ips || []).join(", "), "127.0.0.1, 172.17.0.0/16"), interval: h("input", { class: "rs-input rs-num", type: "number", min: 0, value: Math.round((cfg.snapshot_interval_sec ?? 300) / 60) }), @@ -794,6 +795,7 @@ export class RigSharePanel { allow_guests: f.guests.checked, broadcast_execution: f.broadcast.checked, hide_comfy_account: f.hideAccount.checked, + hide_comfy_file_tabs: f.hideFileTabs.checked, hide_api_templates: f.hideApiTemplates.checked, api_protection: { enabled: f.protect.checked, trusted_ips: f.trusted.value.split(/[\s,]+/).filter(Boolean) }, snapshot_interval_sec: Math.max(0, +f.interval.value || 0) * 60, @@ -808,6 +810,7 @@ export class RigSharePanel { line(f.broadcast, "Share execution progress", "Everyone sees progress and previews, not only whoever queued"), line(f.protect, "Enforce permissions on the ComfyUI API", "Blocks queueing, interrupting and uploads for users without permission"), line(f.hideAccount, "Hide the Comfy.org account login", "Removes ComfyUI's own sign-in button and dialog (used for paid API nodes)"), + line(f.hideFileTabs, "Hide ComfyUI's Workflows and Apps tabs", "RigShare's Files tab replaces them. Their shortcuts open Files instead"), line(f.hideApiTemplates, "Hide API templates", "Removes templates that need paid API nodes from the template browser"), h("label", { class: "rs-label" }, "Trusted IPs / networks (skip login and checks)"), f.trusted, h("div", { class: "rs-row" }, h("label", { class: "rs-label rs-grow" }, "Auto snapshot every (min, 0 = off)"), f.interval), diff --git a/web/rigshare/presence.js b/web/rigshare/presence.js index b93ea01..4989057 100644 --- a/web/rigshare/presence.js +++ b/web/rigshare/presence.js @@ -71,7 +71,7 @@ export class Presence extends EventTarget { sendState() { if (!this.client.online || !this.app.canvas) return; let msg; - if (!this.sync.live || document.hidden) { + if (!this.sync.live || document.hidden || this.sync.inAppMode) { msg = { type: "cursor", x: null, y: null }; } else { const [x, y] = this.app.canvas.graph_mouse ?? [0, 0]; @@ -104,7 +104,7 @@ export class Presence extends EventTarget { } applyViewport(vp, graphId) { - if (graphId !== this.currentGraphId()) return; + if (graphId !== this.currentGraphId() || this.sync.inAppMode) return; const canvas = this.app.canvas; const rect = canvas.canvas.getBoundingClientRect(); canvas.ds.scale = vp.scale; @@ -123,7 +123,7 @@ export class Presence extends EventTarget { draw(ctx) { const visible = new Set(); - if (!this.sync.live || !this.showCursors) return this.pruneCursorEls(visible); + if (!this.sync.live || !this.showCursors || this.sync.inAppMode) return this.pruneCursorEls(visible); const canvas = this.app.canvas; const graph = canvas.graph; const scale = canvas.ds.scale; diff --git a/web/rigshare/save-dialog.js b/web/rigshare/save-dialog.js index e75e92e..67ab563 100644 --- a/web/rigshare/save-dialog.js +++ b/web/rigshare/save-dialog.js @@ -9,8 +9,9 @@ import { FilesBrowser, modal } from "./files.js"; const LAST_FOLDER = "rigshare.lastSaveFolder"; +// ComfyUI adds ".json", or ".app.json" for a workflow saved as an App: names go back without either. function cleanName(name) { - return (name || "").trim().replace(/\.json$/i, "").replace(/[\\/]+/g, "-").slice(0, 120); + return (name || "").trim().replace(/(\.app)?\.json$/i, "").replace(/\.app$/i, "").replace(/[\\/]+/g, "-").slice(0, 120); } export function installSaveDialog(app, api, client, sync) { @@ -75,7 +76,8 @@ export async function chooseLocation(app, api, client, sync, wf) { if (directory === "workflows") return `${result.folder}/${result.name}`; // "Save As" from a file that lives in another folder: write the copy // ourselves, open it, and tell ComfyUI there is nothing left to do. - return await saveCopyElsewhere(app, api, client, wf, `${targetDir}/${result.name}.json`); + const isApp = wf.initialMode === "app" || /\.app(\.json)?$/i.test(wf.filename || ""); + return await saveCopyElsewhere(app, api, client, wf, `${targetDir}/${result.name}${isApp ? ".app" : ""}.json`); } async function saveCopyElsewhere(app, api, client, wf, path) { diff --git a/web/rigshare/sync.js b/web/rigshare/sync.js index 002aafc..770b364 100644 --- a/web/rigshare/sync.js +++ b/web/rigshare/sync.js @@ -222,6 +222,13 @@ export class RoomSync extends EventTarget { return this.app.rootGraph ?? this.app.graph; } + /** The tab on screen shows ComfyUI's App view (no canvas), not the node graph. */ + get inAppMode() { + const wf = this.store?.activeWorkflow; + const mode = wf?.activeMode ?? wf?.initialMode ?? "graph"; + return mode === "app" || mode === "builder:arrange"; + } + serialize() { return stripView(JSON.parse(JSON.stringify(this.graph().serialize()))); } @@ -229,7 +236,7 @@ export class RoomSync extends EventTarget { // ----- which room does a tab belong to -------------------------------- tabName(wf) { - return (wf.filename ?? wf.path?.split("/").pop() ?? "Workflow").replace(/\.json$/, ""); + return (wf.filename ?? wf.path?.split("/").pop() ?? "Workflow").replace(/(\.app)?\.json$/, ""); } /** Why a tab is not live: "unsaved", "private", or null when it is shared. */ @@ -317,6 +324,8 @@ export class RoomSync extends EventTarget { const doc = structuredClone(this.doc); const ds = this.app.canvas?.ds; if (keepView && ds) doc.extra = { ...(doc.extra || {}), ds: { scale: ds.scale, offset: [...ds.offset] } }; + // Graph or App view is each person's own choice: a reload must not switch it. + const mode = wf.activeMode; this.applying++; this.ownLoad++; try { @@ -324,6 +333,7 @@ export class RoomSync extends EventTarget { } finally { this.ownLoad--; this.applying--; + if (mode && wf.activeMode !== mode) wf.activeMode = mode; } this.settle(); this.updateReadOnly();