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.
This commit is contained in:
Edouard Durand
2026-09-26 03:43:46 +00:00
parent 6833141e58
commit 51c16fa93e
14 changed files with 115 additions and 12 deletions
+7
View File
@@ -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.
+4
View File
@@ -29,6 +29,10 @@ RigShare organises ComfyUI's workflows folder like a shared drive. Where a workf
</tr>
</table>
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.
+1
View File
@@ -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 |
+2
View File
@@ -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
+4 -2
View File
@@ -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):
+2
View File
@@ -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.
+4 -1
View File
@@ -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
+27
View File
@@ -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())
+2 -2
View File
@@ -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;
+40
View File
@@ -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();
+4 -1
View File
@@ -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),
+3 -3
View File
@@ -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;
+4 -2
View File
@@ -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) {
+11 -1
View File
@@ -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();