9 Commits
Author SHA1 Message Date
Edouard Durand 9f70cf9205 RigShare 1.2.0
Snapshot on every save (Saved at <time>), New folder prompt above the Save
dialog, lighter Graph marker, changelog, docs, README and screenshots.
2026-09-26 06:26:47 +00:00
Edouard Durand e533e64970 App icon for workflows with an App set up; smaller icons in folder lists 2026-09-26 04:32:38 +00:00
Edouard Durand d6ef325ee1 Files: access button inside a shared folder
The listing reports the shared folder you are in; if you manage it, a lock
button beside New folder opens its access list above the content.
2026-09-26 04:09:18 +00:00
Edouard Durand c1b2bf0d5e Folder access only in the Files tab; refresh screenshots
The Live tab keeps the restricted/open line but no longer edits the folder's
access list. Screenshots show the Files access editor and ComfyUI's Graph and
App icons.
2026-09-26 04:06:24 +00:00
Edouard Durand aadedc9f03 Show who is in the node graph or the App view
Clients report their view; the Live tab marks each avatar and People shows a
Graph/App chip, with ComfyUI's own Graph and App icons (also used for apps
in Files and the Live list).
2026-09-26 04:01:19 +00:00
Edouard Durand 45aebb0776 App mode: RigShare and Files buttons in the App view's own sidebar
Copies ComfyUI's sidebar button so they look the same, keeps the unread badge
and selected state, and opens the panels in a drawer beside the sidebar. The
floating dock remains only as a fallback when the sidebar can't be found.
2026-09-26 03:56:04 +00:00
Edouard Durand 435acb0914 App mode: dock for the RigShare panel and Files
ComfyUI's App view shows a fixed set of sidebar tabs (assets, apps), so
extension tabs vanish there. While the tab on screen is in the App view, a
small dock opens the RigShare panel and Files in a drawer, with the unread
chat badge.
2026-09-26 03:51:51 +00:00
Edouard Durand 51c16fa93e 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.
2026-09-26 03:43:46 +00:00
Edouard Durand 6833141e58 README: remove the TODO list 2026-09-26 03:32:35 +00:00
29 changed files with 576 additions and 70 deletions
+24
View File
@@ -1,5 +1,29 @@
# Changelog
## 1.2.0 (2026-09-26)
ComfyUI's App mode works with RigShare, and every save keeps a snapshot.
### Added
- **App mode**: live workflows work in ComfyUI's App view. Each person keeps their own graph or App view through live edits, App inputs sync, and the app's layout (chosen inputs and outputs) syncs with the workflow. Cursors are off in the App view
- In the App view, where ComfyUI shows only its own sidebar tabs, **RigShare** and **Files** buttons join that sidebar, with the unread chat badge, and open the panels in a drawer beside it
- The Live tab and People show whether each person is in the node graph or the App view, with ComfyUI's own Graph and App icons
- Workflows with an App set up, and workflows saved as Apps (`.app.json`), show ComfyUI's App icon in Files and the Live list. Save keeps an App an App
- **A snapshot on every save**, named *Saved at* and the time (in each viewer's time zone). They rotate like automatic snapshots (`snapshot_keep`)
- Inside a shared folder you own, its 🔒 access button sits next to the new-folder button
### Changed
- 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. The server enforces file permissions either way
- Folder access is managed only in the Files tab. *This tab* in the Live tab still says whether the folder is restricted
- Smaller icons in folder lists
### Fixed
- In the Save and Move dialogs, **New folder** asked for the name in a prompt hidden behind the dialog
- Snapshots taken in the same second could be rotated out of order
## 1.1.0 (2026-09-26)
Workflows now live in folders, like a shared drive, and sharing follows them.
+2 -14
View File
@@ -26,7 +26,8 @@ A custom node that turns a single ComfyUI server into a shared workspace. Everyo
- shows who is online and which workflow they are on, plus live cursors and selections
- lets you follow someone, jumping to their tab and view
- keeps the whole chat history, loading older messages as you scroll up, with an unread badge, a blinking icon and a sound
- keeps per-workflow history: snapshots every few minutes, named snapshots, one-click restore
- keeps per-workflow history: a snapshot on every save and every few minutes, named snapshots, one-click restore
- works in ComfyUI's App mode: everyone picks their own graph or App view, App inputs sync live, and you can see who is in which view
- gives each account permissions to edit, queue, use ComfyUI Manager or administer, enforced by the server and not just hidden in the UI
- keeps simultaneous edits apart: two people adding or connecting nodes at the same moment both keep their work
- issues personal API keys, so scripts and other apps can call ComfyUI with their owner's permissions
@@ -115,16 +116,3 @@ I run one ComfyUI box for several people. [ComfyUI-Nexus](https://github.com/dax
- it hid menus with CSS and monkeypatched litegraph internals that no longer exist
- anyone could become admin with a shared password typed into chat
- everybody worked on one canvas, and opening ComfyUI wiped your own workflow
## TODO
- [x] Login page, JWT sessions, first-run admin setup
- [x] Per-user API keys
- [x] Live co-editing of every shared workflow, per-workflow snapshots
- [x] Cursors, selections, follow mode
- [x] Chat with badge, blink and sound, whole history with scroll back
- [x] Server monitor with floating widget
- [x] Conflict-free node ids when two people add nodes at the same instant
- [x] Private folder per account, shared folders, Save asks where
- [x] Folder access lists (share with specific people)
- [x] Publish to the Comfy registry
+6 -2
View File
@@ -29,15 +29,19 @@ 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 with an App set up (ComfyUI's App mode), or saved as Apps (`.app.json`), show ComfyUI's App icon. Opening an App opens it in the App view.
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.
Hover a row for its actions: **Rename**, **Move to…**, **Delete**, and on a shared folder you own, 🔒 **Who can open it**. Inside a shared folder you own, the same 🔒 sits next to the new-folder button. The folder button creates a folder. In the Shared tab it creates a shared folder that you own.
## Saving
<p align="center"><img src="images/save.png" width="720" alt="Save dialog"></p>
**Save** (Ctrl+S) on a new workflow, and **Save As**, open a dialog with a name and the same three tabs. It starts in My files, or in the folder you last saved to. Pick any folder you can edit and click **Save here**. Clicking an existing workflow reuses its name, and ComfyUI asks before overwriting.
**Save** (Ctrl+S) on a new workflow, and **Save As**, open a dialog with a name and the same three tabs. It starts in My files, or in the folder you last saved to. Pick any folder you can edit and click **Save here**. Clicking an existing workflow reuses its name, and ComfyUI asks before overwriting. Every save also keeps a snapshot in the workflow's history.
## Opening
Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 142 KiB

+2 -1
View File
@@ -27,12 +27,13 @@ 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 |
| `room_expiry_days` | `14` | Forget the live state of workflows nobody opened for this long |
| `snapshot_interval_sec` | `300` | Automatic snapshot interval while editing; `0` turns it off |
| `snapshot_keep` | `30` | Automatic snapshots kept per workflow; named ones are always kept |
| `snapshot_keep` | `30` | Automatic snapshots kept per workflow, and as many on-save ones; named ones are always kept |
| `chat_keep` | `0` | Chat messages kept: `0` keeps the whole history, a number trims it to the latest ones at startup |
## Environment variables
+4 -1
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. The Live tab and People show which view each person is in, using ComfyUI's Graph and App icons. 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. ComfyUI's App view shows only its own sidebar tabs, so RigShare adds **RigShare** and **Files** buttons to that sidebar, with the unread chat badge. They open the panels in a drawer beside it.
Viewers (no *Edit* permission) see a read-only canvas. Anything they change locally is put back.
## When a workflow stops being live
@@ -40,7 +42,7 @@ Viewers (no *Edit* permission) see a read-only canvas. Anything they change loca
## Who can open it
Access follows the folder, not the workflow. To keep a workflow to some people, put it in a shared folder and restrict the folder: in the Files tab (🔒 on the folder), or with **Folder access** under *This tab* when the workflow on screen is in a folder you own. Choose *Only people I choose* and give each person **Can view** or **Can edit**.
Access follows the folder, not the workflow. To keep a workflow to some people, put it in a shared folder and restrict the folder in the Files tab (🔒 on the folder). *This tab* shows whether the workflow on screen is in a restricted folder. Choose *Only people I choose* and give each person **Can view** or **Can edit**.
- Workflows at the top level of Shared (not in a folder) can be opened by everyone with an account, with their usual permissions.
- The folder's owner (whoever created it) and admins always have access.
@@ -54,6 +56,7 @@ Saved workflow files still show up by name in ComfyUI's workflow browser; openin
## Snapshots
- Automatic every 5 minutes while a workflow is being edited (`snapshot_interval_sec`). The last 30 are kept (`snapshot_keep`).
- On every save, by anyone, of any workflow: named *Saved at* and the time, shown in your own time zone. The last 30 are kept too.
- **Snapshot** saves a named one, which is never rotated out.
- **History** lists them. ↗ opens a private copy; ⟲ restores it for everyone, after first saving the current state as a snapshot.
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-rigshare"
description = "Multi-user collaboration for ComfyUI: accounts and login, live-shared workflows, cursors, chat, snapshots and a server monitor."
version = "1.1.0"
version = "1.2.0"
license = { file = "LICENSE" }
dependencies = []
+13 -4
View File
@@ -21,7 +21,7 @@ from aiohttp import web, WSMsgType
from .chatlog import ChatLog
from .graphdoc import apply_patch, remap_conflicts
from .stats import collect_stats
from .workspace import Workspace
from .workspace import Workspace, has_app
log = logging.getLogger("ComfyUI-RigShare")
@@ -76,12 +76,13 @@ class Client:
self.joined = time.time()
self.idle = False
self.room = None
self.view = "graph" # "graph" (node editor) or "app" (ComfyUI's App view)
def public(self):
return {"id": self.id, "kind": self.kind, "key": self.key if self.kind == "user" else None,
"guest_id": self.key if self.kind == "guest" else None,
"name": self.name, "color": self.color, "perms": self.perms,
"joined": int(self.joined), "idle": self.idle, "room": self.room}
"joined": int(self.joined), "idle": self.idle, "room": self.room, "view": self.view}
class Hub:
@@ -283,7 +284,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") or has_app(room.doc)})
out.sort(key=lambda r: (-len(r["members"]), -r["updated"]))
return out
@@ -318,7 +320,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 +375,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):
@@ -503,6 +506,12 @@ class Hub:
await self.broadcast_presence()
await self.system_chat(f"{old} is now known as {name}")
elif kind == "view":
view = data.get("view")
if view in ("graph", "app") and view != client.view:
client.view = view
await self.broadcast_presence()
elif kind == "idle":
client.idle = bool(data.get("idle"))
await self.broadcast_presence()
+19
View File
@@ -609,6 +609,20 @@ def setup(server, store, hub):
action = {"GET": "get", "HEAD": "get", "POST": "save", "DELETE": "delete"}.get(request.method)
return (action, src, None) if action else None
def snapshot_saved_file(request, path, body):
if not path.endswith(".json") or Workspace.workflows_rel(path) is None:
return
try:
doc = json.loads(body)
except ValueError:
return
if not isinstance(doc, dict) or "nodes" not in doc:
return
name = path.rsplit("/", 1)[-1].removesuffix(".json").removesuffix(".app")
# The panel shows the time in each viewer's own time zone; the label is for API clients.
label = "Saved at " + time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime())
store.add_snapshot(doc, display(request), "file:" + path, name, label, kind="save")
def check_userdata(request, action, src, dest):
"""Folder rules (private spaces, restricted shared folders)."""
kind, key, perms = who(request)
@@ -705,7 +719,12 @@ def setup(server, store, hub):
"details": "", "extra_info": {}}, "node_errors": {}},
status=403)
break
if userdata and userdata[0] == "save":
await request.read() # keep the body for the snapshot below (aiohttp caches it)
response = await handler(request)
# Every save of a workflow keeps a snapshot, named after when it was saved.
if userdata and userdata[0] == "save" and getattr(response, "status", 0) == 200:
snapshot_saved_file(request, userdata[1], await request.read())
# Workflow listings only show what this person may open.
if userdata and userdata[0] == "list" and not trusted(request.remote or "") and hub.workspace:
response = filter_listing(request, response, userdata[1])
+14 -5
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.
@@ -442,8 +444,11 @@ class Store:
items.append({"id": name[:-5], **meta})
return items
def add_snapshot(self, doc, author, room, room_name, label=None):
snap_id = time.strftime("%Y%m%d-%H%M%S") + "-" + secrets.token_hex(2)
def add_snapshot(self, doc, author, room, room_name, label=None, kind=None):
"""``kind`` "save": taken when the file was saved; those rotate like automatic ones."""
# Sorted by name, newest first: include microseconds so saves in the same second keep their order.
now = time.time()
snap_id = time.strftime("%Y%m%d-%H%M%S", time.localtime(now)) + f"-{int(now % 1 * 1e6):06d}-" + secrets.token_hex(2)
meta = {
"time": int(time.time()),
"author": author,
@@ -452,6 +457,8 @@ class Store:
"room_name": room_name,
"nodes": len(doc.get("nodes", [])) if isinstance(doc, dict) else 0,
}
if kind:
meta["kind"] = kind
with open(os.path.join(self.snapshot_dir, snap_id + ".json"), "w", encoding="utf-8") as f:
json.dump({"meta": meta, "doc": doc}, f)
self._prune_snapshots(room)
@@ -468,9 +475,11 @@ class Store:
def _prune_snapshots(self, room):
keep = max(1, int(self.config.get("snapshot_keep", 30)))
# Labelled (manual) snapshots are kept; only automatic ones rotate.
auto = [s for s in self.list_snapshots(room) if not s.get("label")]
for snap in auto[keep:]:
# Named (manual) snapshots are kept; automatic and on-save ones rotate, each on its own.
snaps = self.list_snapshots(room)
auto = [s for s in snaps if not s.get("label") and not s.get("kind")]
saves = [s for s in snaps if s.get("kind") == "save"]
for snap in auto[keep:] + saves[keep:]:
try:
os.remove(os.path.join(self.snapshot_dir, snap["id"] + ".json"))
except OSError:
+40 -2
View File
@@ -11,6 +11,7 @@ Paths handled here are relative to the *userdata* root, e.g.
every path is fully URL-decoded and normalised before it is checked.
"""
import json
import os
import posixpath
import re
@@ -40,6 +41,13 @@ def norm(path):
return posixpath.normpath("/" + path).lstrip("/") if path else ""
def has_app(doc):
"""A workflow with an App set up (ComfyUI App mode: chosen inputs or outputs)."""
data = (doc or {}).get("extra") if isinstance(doc, dict) else None
linear = (data or {}).get("linearData") if isinstance(data, dict) else None
return isinstance(linear, dict) and bool(linear.get("inputs") or linear.get("outputs"))
def min_role(a, b):
return a if ROLE_RANK[a] <= ROLE_RANK[b] else b
@@ -51,6 +59,7 @@ class Workspace:
self.folders = store._read("folders.json", {}) # "shared/<name>" -> {owner, acl, created}
os.makedirs(os.path.join(root, "users"), exist_ok=True)
os.makedirs(os.path.join(root, "shared"), exist_ok=True)
self._app_cache = {} # absolute path -> (mtime, size, has an App)
def save(self):
self.store._write("folders.json", self.folders)
@@ -191,10 +200,29 @@ class Workspace:
def _abs(self, rel):
return os.path.join(self.root, *[p for p in rel.split("/") if p])
def _file_has_app(self, full):
"""Read a workflow file only when it changed since the last check."""
try:
st = os.stat(full)
except OSError:
return False
cached = self._app_cache.get(full)
if cached and cached[0] == st.st_mtime and cached[1] == st.st_size:
return cached[2]
try:
with open(full, "r", encoding="utf-8") as f:
text = f.read()
found = '"linearData"' in text and has_app(json.loads(text))
except (OSError, ValueError):
found = False
self._app_cache[full] = (st.st_mtime, st.st_size, found)
return found
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 +230,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 or (not is_dir and self._file_has_app(full)):
entry["app"] = True
entry["deletable"] = self.can_delete(rel, kind, key, perms)
return entry
@@ -249,7 +279,15 @@ class Workspace:
entries.append(self._entry(child, name, os.path.isdir(full), kind, key, perms))
can_mkdir = role == "edit" and rel not in ("", "users", "shared")
entries.sort(key=lambda e: (e["type"] != "folder", e["name"].lower()))
return {"path": rel, "role": role, "entries": entries, "can_mkdir": can_mkdir}
out = {"path": rel, "role": role, "entries": entries, "can_mkdir": can_mkdir}
# Inside a shared folder: which one, and whether this person manages its access.
area, folder = self.area(rel) if rel != "@shared" else (None, None)
if area == "shared":
meta = self.folders.get(folder) or {}
out["shared_folder"] = {"path": folder, "name": folder.split("/", 1)[1], "owner": meta.get("owner"),
"restricted": bool(meta.get("acl")),
"manage": self.can_manage(folder, kind, key, perms)}
return out
def make_dir(self, parent, name, kind, key, perms):
"""New folder. In the Shared root it becomes a shared folder you own."""
+116
View File
@@ -324,3 +324,119 @@ 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())
def test_view_mode_in_presence(run):
async def scenario():
async with rig_server() as rig:
await rig.setup_admin()
await rig.add_user("alice", edit=True)
alice, boss = await rig.ws("alice"), await rig.ws("boss")
await boss.drain()
await alice.send({"type": "view", "view": "app"})
presence = await boss.until("presence")
assert {u["key"]: u["view"] for u in presence["users"]} == {"alice": "app", "boss": "graph"}
await alice.send({"type": "view", "view": "nonsense"})
await asyncio.sleep(0.2)
assert next(c for c in rig.hub.clients.values() if c.key == "alice").view == "app", "unknown views are ignored"
await alice.close(); await boss.close()
run(scenario())
def test_listing_inside_a_shared_folder(run):
async def scenario():
async with rig_server() as rig:
await rig.setup_admin()
alice = await rig.add_user("alice", edit=True)
bob = await rig.add_user("bob", edit=True)
h = rig.http
await h.post(rig.url("/rigshare/api/workspace/mkdir"), json={"parent": "@shared", "name": "FaceSwap"}, headers=alice)
await h.post(rig.url("/rigshare/api/workspace/mkdir"), json={"parent": "shared/FaceSwap", "name": "old"}, headers=alice)
async def here(user, path):
r = await h.get(rig.url(f"/rigshare/api/workspace/list?path={path}"), headers=user)
return (await r.json()).get("shared_folder")
# The folder you are in, even deeper down, and whether you manage its access.
assert await here(alice, "shared/FaceSwap/old") == {"path": "shared/FaceSwap", "name": "FaceSwap", "owner": "alice",
"restricted": False, "manage": True}
assert (await here(bob, "shared/FaceSwap"))["manage"] is False
assert await here(alice, "users/alice") is None
run(scenario())
def test_workflows_with_an_app_set_up(run):
async def scenario():
async with rig_server() as rig:
await rig.setup_admin()
alice = await rig.add_user("alice", edit=True)
h = rig.http
with_app = json.dumps({"nodes": [], "extra": {"linearData": {"inputs": [[3, "seed"]], "outputs": [9]}}}).encode()
empty_app = json.dumps({"nodes": [], "extra": {"linearData": {"inputs": [], "outputs": []}}}).encode()
for name, body in (("faceswap.json", with_app), ("empty.json", empty_app), ("plain.json", b"{}")):
await h.post(rig.url(f"/api/userdata/{enc('workflows/users/alice/' + name)}"), data=body, headers=alice)
async def apps():
listing = await (await h.get(rig.url("/rigshare/api/workspace/list?path=users/alice"), headers=alice)).json()
return {e["name"] for e in listing["entries"] if e.get("app")}
assert await apps() == {"faceswap"}, "only a workflow whose App has inputs or outputs"
# Edited on disk: the cached answer follows the file.
await h.post(rig.url(f"/api/userdata/{enc('workflows/users/alice/faceswap.json')}"), data=b'{"nodes": []}', headers=alice)
assert await apps() == set()
sock = await rig.ws("alice")
await sock.send({"type": "join", "room": "file:workflows/shared/f.json", "name": "f", "doc": json.loads(with_app)})
await sock.until("room")
presence = [m for m in await sock.drain() if m["type"] == "presence"][-1]
assert presence["rooms"][0]["app"] is True
await sock.close()
run(scenario())
def test_every_save_keeps_a_snapshot(run):
async def scenario():
async with rig_server({"snapshot_keep": 2}) as rig:
await rig.setup_admin()
alice = await rig.add_user("alice", edit=True)
h = rig.http
path = "workflows/shared/flow.json"
for n in range(3):
doc = json.dumps({"nodes": [{"id": i} for i in range(n + 1)], "links": []}).encode()
assert (await h.post(rig.url(f"/api/userdata/{enc(path)}"), data=doc, headers=alice)).status == 200
await h.post(rig.url(f"/api/userdata/{enc('workflows/notes.json')}"), data=b'{"not": "a workflow"}', headers=alice)
snaps = await (await h.get(rig.url(f"/rigshare/api/snapshots?room={enc('file:' + path)}"), headers=alice)).json()
assert [s["nodes"] for s in snaps] == [3, 2], "newest first, rotated to snapshot_keep"
assert all(s["kind"] == "save" and s["label"].startswith("Saved at ") and s["author"] == "alice" for s in snaps)
assert not rig.store.list_snapshots("file:workflows/notes.json"), "only workflows"
# A save that is refused keeps nothing.
viewer = await rig.add_user("viewer")
await h.post(rig.url(f"/api/userdata/{enc(path)}"), data=b'{"nodes": []}', headers=viewer)
assert len(rig.store.list_snapshots("file:" + path)) == 2
run(scenario())
+150
View File
@@ -0,0 +1,150 @@
// RigShare in ComfyUI's App mode.
//
// The App view's sidebar shows a fixed set of tabs (assets and apps), so the
// RigShare and Files tabs disappear there. While the tab on screen is in the
// App view, RigShare adds its own buttons to that sidebar, copied from
// ComfyUI's so they look the same, and opens the panels in a drawer next to it.
// If the sidebar can't be found, a small floating dock offers them instead.
import { h } from "./ui.js";
const RAIL = 'nav[data-testid="side-toolbar"]';
export class AppDock {
/**
* views: { id: { icon, title, mount(el), unmount() } }
*/
constructor({ sync, views }) {
this.sync = sync;
this.views = views;
this.open = null;
this.badges = {};
this.railButtons = {};
this.dockButtons = {};
this.body = h("div", { class: "rs-host rs-dock-drawer-body" });
this.drawer = h("div", { class: "rs-dock-drawer rs-panel-vars", role: "dialog" },
h("button", { class: "rs-dock-close", title: "Close", onclick: () => this.show(null) }, h("i", { class: "pi pi-times" })),
this.body);
this.dock = h("div", { class: "rs-dock rs-panel-vars" }, ...Object.entries(views).map(([id, v]) => {
const badge = h("span", { class: "rs-dock-badge" });
const btn = h("button", {
class: "rs-dock-btn", title: v.title, "aria-label": v.title,
onclick: () => this.toggle(id),
}, h("i", { class: `pi ${v.icon}` }), badge);
this.dockButtons[id] = { btn, badge };
return btn;
}));
this.dock.style.display = "none";
this.drawer.style.display = "none";
document.body.append(this.dock, this.drawer);
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && this.open) this.show(null); });
setInterval(() => this.update(), 400);
}
toggle(id) {
this.show(this.open === id ? null : id);
}
update() {
const active = this.sync.inAppMode;
const rail = active ? document.querySelector(RAIL) : null;
if (rail) this.addToRail(rail);
else this.removeFromRail();
this.dock.style.display = active && !rail ? "" : "none";
if (!active && this.open) this.show(null);
if (this.open) this.place(rail);
}
/** Copy one of ComfyUI's sidebar buttons for each view, placed after its own tabs. */
addToRail(rail) {
const ours = Object.values(this.railButtons);
if (ours.length && ours.every((b) => rail.contains(b.btn))) return;
const tabs = [...rail.querySelectorAll('[data-testid$="-tab-button"]')].filter((b) => !b.dataset.rigshare);
const template = tabs.at(-1);
if (!template) return;
this.removeFromRail();
let after = template;
for (const [id, view] of Object.entries(this.views)) {
const btn = template.cloneNode(true);
btn.removeAttribute("data-testid");
btn.dataset.rigshare = id;
btn.classList.remove("side-bar-button-selected");
btn.setAttribute("aria-label", view.title);
btn.title = view.title;
const icon = btn.querySelector(".side-bar-button-icon");
if (icon) {
const mine = document.createElement("i");
mine.className = `pi ${view.icon} side-bar-button-icon`;
mine.style.fontSize = "18px";
icon.replaceWith(mine);
}
btn.querySelector(".sidebar-icon-badge")?.remove();
const label = [...btn.querySelectorAll("span")].find((s) => s.textContent.trim());
if (label) label.textContent = view.title;
const badge = document.createElement("span");
badge.className = "sidebar-icon-badge rs-rail-badge";
const holder = btn.querySelector(".side-bar-button-icon")?.parentElement ?? btn;
holder.style.position = "relative"; // the badge sits on the icon's corner
holder.append(badge);
btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); this.toggle(id); });
after.after(btn);
after = btn;
this.railButtons[id] = { btn, badge };
}
this.refreshButtons();
}
removeFromRail() {
for (const b of Object.values(this.railButtons)) b.btn.remove();
this.railButtons = {};
}
/** Beside the sidebar when there is one, above the floating dock otherwise. */
place(rail) {
const d = this.drawer.style;
if (rail) {
const r = rail.getBoundingClientRect();
d.left = `${Math.round(r.right + 8)}px`;
d.top = `${Math.round(r.top + 8)}px`;
d.bottom = "8px";
} else {
d.left = "64px";
d.top = "8px";
d.bottom = "64px";
}
}
show(id) {
if (this.open) this.views[this.open].unmount?.();
this.open = id;
this.refreshButtons();
this.drawer.style.display = id ? "" : "none";
if (id) {
this.place(document.querySelector(RAIL));
this.views[id].mount(this.body);
} else {
this.body.replaceChildren();
}
}
refreshButtons() {
for (const [id, b] of Object.entries(this.railButtons)) {
b.btn.classList.toggle("side-bar-button-selected", id === this.open);
this.paintBadge(b.badge, this.badges[id]);
}
for (const [id, b] of Object.entries(this.dockButtons)) {
b.btn.classList.toggle("active", id === this.open);
this.paintBadge(b.badge, this.badges[id]);
}
}
paintBadge(el, text) {
el.textContent = text || "";
el.style.display = text ? "block" : "none";
}
setBadge(id, text) {
this.badges[id] = text || null;
this.refreshButtons();
}
}
+41 -5
View File
@@ -7,7 +7,7 @@
// One component serves the Files tab ("browse"), the "Open from RigShare"
// dialog ("open"), the Save dialog ("save") and the "Move to…" picker ("pick").
import { h, timeAgo, storage } from "./ui.js";
import { h, timeAgo, storage, APP_ICON } from "./ui.js";
const SHARED = "@shared";
const ALL = "";
@@ -161,7 +161,14 @@ export class FilesBrowser {
h("button", { class: `rs-fb-crumb ${i === trail.length - 1 ? "current" : ""}`, onclick: () => this.go(c.path) },
c.icon ? h("i", { class: `pi ${c.icon}` }) : null, i === 0 && c.icon ? null : c.label),
]).filter(Boolean));
const shared = this.listing?.shared_folder;
this.actions.replaceChildren(...[
// Inside a shared folder: its access list, like the 🔒 on its row one level up.
shared?.manage && this.mode === "browse" && this.accessEditor ? h("button", {
class: `rs-btn rs-btn-icon ${this.accessFor === shared.path ? "active" : ""}`,
title: `Who can open "${shared.name}"${shared.restricted ? " (restricted)" : ""}`,
onclick: () => { this.accessFor = this.accessFor === shared.path ? null : shared.path; this.render(); },
}, h("i", { class: `pi ${shared.restricted ? "pi-lock" : "pi-lock-open"}` })) : null,
this.listing?.can_mkdir && this.mode !== "open" ? h("button", {
class: "rs-btn rs-btn-icon", title: this.path === SHARED ? "New shared folder" : "New folder",
onclick: () => this.newFolder(),
@@ -173,6 +180,14 @@ export class FilesBrowser {
renderList() {
const entries = (this.listing?.entries || []).filter((e) => !this.filter || e.name.toLowerCase().includes(this.filter));
// The access editor of the shared folder we are in shows above its content.
const here = this.listing?.shared_folder;
const top = here && this.accessFor === here.path && this.accessEditor
? this.accessEditor(here.path, () => { this.accessFor = null; this.reload(); }) : null;
if (top) {
this.list.replaceChildren(top, ...entries.map((e) => this.row(e)));
return;
}
if (!entries.length) {
this.list.replaceChildren(h("div", { class: "rs-fb-empty" },
this.filter ? "Nothing matches." : this.path === `users/${this.me}` ? "Nothing here yet. Save a workflow and choose My files." : "This folder is empty."));
@@ -191,8 +206,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 ? "app" : "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;
@@ -207,7 +222,7 @@ export class FilesBrowser {
if (this.mode !== "pick") this.onOpenFile?.(`workflows/${e.path}`);
},
},
h("i", { class: `pi ${icon} rs-fb-icon ${isFolder ? "folder" : ""}` }),
h("i", { class: `${icon === "app" ? `${APP_ICON} rs-comfy-icon` : `pi ${icon}`} rs-fb-icon ${isFolder ? "folder" : ""}` }),
h("span", { class: "rs-grow rs-min0" },
h("div", { class: "rs-name rs-ellipsis", title: e.name }, e.name,
live ? h("span", { class: "rs-live rs-inline-icon", title: `Live · ${live.members.length} open` }, h("span", { class: "rs-live-dot" })) : null),
@@ -234,6 +249,8 @@ export class FilesBrowser {
// ----- actions ----------------------------------------------------------
async ask(title, message, value = "") {
// Inside the Save / Move / Open dialogs ComfyUI's own prompt would open underneath.
if (this.mode !== "browse") return await promptModal(title, message, value);
const dialog = this.app.extensionManager?.dialog;
if (dialog?.prompt) return await dialog.prompt({ title, message, defaultValue: value });
return window.prompt(message, value);
@@ -316,7 +333,13 @@ export function modal(title, icon, body, footer) {
document.removeEventListener("keydown", onKey, true);
resolveClose(value);
};
const onKey = (e) => { if (e.key === "Escape" && backdrop.isConnected) { e.stopPropagation(); close(null); } };
// Stacked modals (a prompt over the Save dialog): Escape closes only the top one.
const onKey = (e) => {
if (e.key !== "Escape" || !backdrop.isConnected) return;
if ([...document.querySelectorAll(".rs-modal-backdrop")].at(-1) !== backdrop) return;
e.stopPropagation();
close(null);
};
const backdrop = h("div", { class: "rs-modal-backdrop rs-panel-vars", onmousedown: (e) => { if (e.target === backdrop) close(null); } },
h("div", { class: "rs-modal rs-modal-wide", role: "dialog", "aria-label": title },
h("div", { class: "rs-modal-title" }, h("i", { class: `pi ${icon}` }), h("span", { class: "rs-grow" }, title),
@@ -329,6 +352,19 @@ export function modal(title, icon, body, footer) {
return { close, closed };
}
/** A text prompt in RigShare's own modal, so it stacks above another RigShare modal. */
export async function promptModal(title, message, value = "") {
const input = h("input", { class: "rs-input", value, maxLength: 120 });
const ok = h("button", { class: "rs-btn rs-btn-primary" }, "OK");
const m = modal(title, "pi-pencil", h("div", { class: "rs-save-body" }, h("label", { class: "rs-label" }, message), input),
[h("span", { class: "rs-grow" }), h("button", { class: "rs-btn rs-btn-ghost", onclick: () => m.close(null) }, "Cancel"), ok]);
const submit = () => m.close(input.value);
ok.onclick = submit;
input.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") { e.preventDefault(); submit(); } });
setTimeout(() => { input.focus(); input.select(); }, 30);
return await m.closed;
}
/** Folder picker; resolves to a folder path ("" = common top level) or null. */
export async function pickFolder({ app, client, sync, title, start }) {
const browser = new FilesBrowser({ app, client, sync, mode: "pick", start });
+54
View File
@@ -11,6 +11,7 @@ import { STYLES } from "./styles.js";
import { installSaveDialog } from "./save-dialog.js";
import { FilesBrowser, openFromWorkspace } from "./files.js";
import { h } from "./ui.js";
import { AppDock } from "./app-dock.js";
const TAB_ID = "rigshare";
const ICON_CLASS = "rigshare-tab-icon";
@@ -93,6 +94,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 +275,7 @@ app.registerExtension({
installQueueGuard(client);
installManagerGuard(client);
installComfyAccountGuard(client);
installFileTabsGuard(client);
installSaveDialog(app, api, client, sync);
presence.install();
@@ -290,11 +331,24 @@ app.registerExtension({
});
client.on("self", () => { if (filesPanel.isConnected) filesBrowser.reload(); });
// ComfyUI's App view hides extension sidebar tabs: offer them in a dock there.
const dock = new AppDock({
sync,
views: {
[TAB_ID]: { icon: "pi-users", title: "RigShare", mount: (el) => panel.mount(el), unmount: () => panel.unmount() },
files: {
icon: "pi-folder-open", title: "Files",
mount: (el) => { el.replaceChildren(filesPanel); filesBrowser.reload(); },
},
},
});
// 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;
dock.setBadge(TAB_ID, badge);
setBlink(!!badge);
};
panel.onNotify = () => {
+33 -26
View File
@@ -1,7 +1,7 @@
// Sidebar panel: tabs for workflows, people, chat, server and admin, plus an
// account view (profile, password, API keys) behind the avatar button.
import { h, initials, timeAgo, clock, roleChips, meter, storage } from "./ui.js";
import { h, initials, timeAgo, clock, roleChips, meter, storage, viewIcon, APP_ICON } from "./ui.js";
import { ServerWidget } from "./server-widget.js";
import { pickFolder } from "./files.js";
import { isPrivatePath } from "./sync.js";
@@ -16,6 +16,14 @@ const VIEWS = [
{ id: "admin", icon: "pi-shield", label: "Admin", admin: true },
];
/** A snapshot's name; on-save ones show the save time in the viewer's own time zone. */
function snapLabel(snap) {
if (snap.kind === "save") {
return `Saved at ${new Date(snap.time * 1000).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}`;
}
return snap.label || "Auto";
}
export class RigSharePanel {
constructor(app, client, sync, presence) {
this.app = app;
@@ -247,9 +255,8 @@ export class RigSharePanel {
s.live
? h("span", { class: "rs-live" }, h("span", { class: "rs-live-dot" }), `Live · rev ${s.version}${last}`)
: h("span", { class: "rs-live rs-muted" }, h("i", { class: "pi pi-spin pi-spinner" }), "Joining…"),
h("span", { class: "rs-avatars" }, ...here.map((u) => h("span", { class: "rs-avatar rs-avatar-xs", style: `background:${u.color}`, title: u.name }, initials(u.name))))));
h("span", { class: "rs-avatars" }, ...here.map((u) => this.viewAvatar(u)))));
body.push(this.accessLine());
if (this.accessOpen && s.room?.manage && s.room.folder) body.push(this.accessEditor(s.room.folder));
if (!s.canEdit) body.push(h("p", { class: "rs-note" }, h("i", { class: "pi pi-lock" }), c.perms.edit ? "View only in this workflow." : "View only — ask an admin for edit access."));
if (s.canEdit) {
row.append(h("button", {
@@ -278,7 +285,7 @@ export class RigSharePanel {
await this.run(() => this.sync.movePath(wf.path, dest ? `workflows/${dest}/${base}` : `workflows/${base}`), "Moved: it is live now");
}
/** Who can open the workflow on screen: that is decided by its folder. */
/** Who can open the workflow on screen: decided by its folder, managed in the Files tab. */
accessLine() {
const info = this.sync.room;
if (!info) return null;
@@ -286,24 +293,10 @@ export class RigSharePanel {
return h("div", { class: "rs-access" },
h("i", { class: `pi ${info.restricted ? "pi-lock" : "pi-globe"}` }),
h("span", { class: "rs-grow" }, info.restricted ? `Only people with access to "${folder}"` : "Everyone can open it",
folder && !info.restricted ? h("span", { class: "rs-muted" }, ` · in "${folder}"`) : null),
info.manage ? h("button", {
class: `rs-btn rs-btn-sm ${this.accessOpen ? "active" : ""}`, title: `Who can open the folder "${folder}"`,
onclick: () => { this.accessOpen = !this.accessOpen; this.accessDraft = null; this.renderBody(); },
}, "Folder access") : null);
folder && !info.restricted ? h("span", { class: "rs-muted" }, ` · in "${folder}"`) : null));
}
accessEditor(folder) {
return this.accessEditorFor({
load: () => this.client.request("GET", `/rigshare/api/workspace/folder?path=${encodeURIComponent(folder)}`),
save: (restricted, members) => this.client.request("PATCH", "/rigshare/api/workspace/folders", { path: folder, restricted, members }),
everyoneHint: "Anyone with an account can open its workflows, with their usual permissions",
close: () => { this.accessOpen = false; this.accessDraft = null; this.renderBody(); },
draftKey: "accessDraft",
});
}
/** Access editor for a shared folder (Files tab and the live workflow's folder). */
/** Access editor for a shared folder (used by the Files tab). */
accessEditorFor({ load, save, everyoneHint, close, draftKey }) {
const box = h("div", { class: "rs-access-editor" }, h("p", { class: "rs-muted" }, "Loading…"));
Promise.all([this.run(load), this.run(() => this.client.request("GET", "/rigshare/api/people"))]).then(([acl, people]) => {
@@ -363,12 +356,12 @@ export class RigSharePanel {
}
this.historyEl.replaceChildren(...snaps.map((snap) => h("div", { class: "rs-row-item" },
h("div", { class: "rs-grow rs-min0" },
h("div", { class: "rs-ellipsis" }, snap.label || "Auto", h("span", { class: "rs-muted" }, ` · ${snap.nodes} nodes`)),
h("div", { class: "rs-ellipsis" }, snapLabel(snap), h("span", { class: "rs-muted" }, ` · ${snap.nodes} nodes`)),
h("div", { class: "rs-muted rs-small" }, `${timeAgo(snap.time * 1000)} · ${snap.author}`)),
h("button", { class: "rs-btn rs-btn-icon", title: "Open a private copy", onclick: () => this.openSnapshot(snap) }, h("i", { class: "pi pi-external-link" })),
this.sync.canEdit ? h("button", {
class: "rs-btn rs-btn-icon", title: "Restore for everyone", onclick: async () => {
if (!(await this.confirm("Restore snapshot", `Restore "${snap.label || "Auto"}" for everyone in this workflow?`))) return;
if (!(await this.confirm("Restore snapshot", `Restore "${snapLabel(snap)}" for everyone in this workflow?`))) return;
await this.run(() => this.client.request("POST", `/rigshare/api/snapshots/${snap.id}/restore`), "Snapshot restored");
this.loadHistory();
},
@@ -380,7 +373,16 @@ export class RigSharePanel {
if (!data) return;
const doc = structuredClone(data.doc);
if (doc.extra) delete doc.extra.rigshare; // a private copy, not the live room
await this.app.loadGraphData(doc, true, true, `${snap.room_name || "Snapshot"} (${snap.label || snap.id}).json`);
await this.app.loadGraphData(doc, true, true, `${snap.room_name || "Snapshot"} (${snapLabel(snap).replace(/[\/:]/g, "-")}).json`);
}
/** Small avatar marked with how that person views the workflow: node graph or App. */
viewAvatar(u) {
const app = u.view === "app";
return h("span", {
class: `rs-avatar rs-avatar-xs rs-view-avatar ${app ? "rs-view-app" : ""}`, style: `background:${u.color}`,
title: `${u.name} · ${app ? "App view" : "editing the graph"}`,
}, initials(u.name), h("span", { class: "rs-view-mark" }, viewIcon(u.view)));
}
roomsSection() {
@@ -392,11 +394,11 @@ 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: `${r.app ? `${APP_ICON} rs-comfy-icon` : "pi 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`),
h("div", { class: "rs-avatars" }, ...members.map((u) => h("span", { class: "rs-avatar rs-avatar-xs", style: `background:${u.color}`, title: u.name }, initials(u.name))))),
h("div", { class: "rs-avatars" }, ...members.map((u) => this.viewAvatar(u)))),
here ? h("span", { class: "rs-chip rs-chip-edit" }, "open") : h("button", {
class: "rs-btn rs-btn-icon", title: "Open",
onclick: () => this.run(() => this.sync.openRoom(r)),
@@ -432,7 +434,9 @@ export class RigSharePanel {
h("div", { class: "rs-name rs-ellipsis" }, u.name, self ? h("span", { class: "rs-muted" }, " (you)") : null,
u.idle ? h("i", { class: "pi pi-moon rs-muted rs-inline-icon", title: "Away" }) : null),
h("div", { class: "rs-chips" }, ...(toggles.length ? toggles : roleChips(u.perms))),
h("div", { class: "rs-where" }, h("i", { class: `pi ${where ? "pi-file" : "pi-lock"}` }), h("span", { class: "rs-ellipsis" }, where ?? "private tab"))),
h("div", { class: "rs-where" }, h("i", { class: `pi ${where ? "pi-file" : "pi-lock"}` }), h("span", { class: "rs-ellipsis" }, where ?? "private tab"),
where ? h("span", { class: `rs-chip rs-view-chip ${u.view === "app" ? "rs-view-app" : ""}` },
viewIcon(u.view), u.view === "app" ? "App" : "Graph") : null)),
self ? null : h("button", {
class: `rs-btn rs-btn-icon ${following ? "rs-btn-primary" : ""}`,
title: following ? "Stop following" : `Follow ${u.name} (jump to their tab and view)`,
@@ -777,6 +781,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 +799,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 +814,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) {
+27 -2
View File
@@ -94,7 +94,7 @@ code { font-family: ui-monospace, monospace; font-size: 11px; background: var(--
.rs-avatar-sm { width: 28px; height: 28px; font-size: 10.5px; }
.rs-avatar-lg { width: 44px; height: 44px; font-size: 15px; }
.rs-avatar-xs { width: 20px; height: 20px; font-size: 8.5px; }
.rs-avatars { display: inline-flex; gap: 3px; flex-wrap: wrap; margin-top: 3px; }
.rs-avatars { display: inline-flex; gap: 7px; flex-wrap: wrap; margin-top: 3px; }
.rs-name { font-weight: 600; }
.rs-me { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.rs-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
@@ -204,7 +204,7 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; }
.rs-fb-main { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; padding: 7px 8px; border: 0; background: none;
color: inherit; font: inherit; text-align: left; cursor: pointer; }
.rs-fb-main:disabled { cursor: default; }
.rs-fb-icon { font-size: 16px; color: var(--rs-muted); width: 18px; text-align: center; flex: none; }
.rs-fb-icon { font-size: 13px; color: var(--rs-muted); width: 15px; text-align: center; flex: none; }
.rs-fb-icon.folder { color: #f5b942; }
.rs-fb-tools-row { display: none; gap: 3px; padding-right: 6px; }
.rs-fb-row:hover .rs-fb-tools-row, .rs-fb-row:focus-within .rs-fb-tools-row { display: flex; }
@@ -244,6 +244,31 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; }
.rs-mini-queue { color: var(--rs-muted); text-align: center; margin-top: 2px; }
/* remote cursors (DOM layer above canvas content) */
.rs-dock { position: fixed; left: 64px; bottom: 16px; z-index: 1100; display: flex; gap: 4px; padding: 4px;
background: var(--rs-bg); border: 1px solid var(--rs-border); border-radius: 12px; box-shadow: 0 6px 24px rgb(0 0 0 / .35); }
.rs-dock-btn { position: relative; width: 36px; height: 36px; display: grid; place-items: center; border: 0; border-radius: 8px;
background: none; color: var(--rs-muted); cursor: pointer; }
.rs-dock-btn .pi { font-size: 16px; }
.rs-dock-btn:hover { color: var(--rs-fg); background: var(--rs-bg2); }
.rs-dock-btn.active { color: var(--rs-accent-fg); background: var(--rs-accent); }
.rs-dock-badge { display: none; position: absolute; top: 1px; right: 1px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px;
background: #ef4444; color: #fff; font-size: 10px; line-height: 16px; font-weight: 700; }
.rs-dock-drawer { position: fixed; top: 8px; bottom: 64px; left: 64px; width: min(380px, calc(100vw - 80px)); z-index: 1100;
background: var(--rs-bg); border: 1px solid var(--rs-border); border-radius: 12px; box-shadow: 0 12px 40px rgb(0 0 0 / .45); overflow: hidden; }
.rs-dock-drawer-body { height: 100%; }
.rs-dock-drawer .rs-header { padding-right: 40px; }
.rs-rail-badge { position: absolute; top: -4px; right: -6px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px;
background: #ef4444; color: #fff; font-size: 10px; line-height: 16px; font-weight: 700; text-align: center; }
.rs-dock-close { position: absolute; top: 8px; right: 8px; z-index: 2; border: 0; background: none; color: var(--rs-muted); cursor: pointer; padding: 4px; }
.rs-dock-close:hover { color: var(--rs-fg); }
.rs-view-avatar { position: relative; overflow: visible; }
.rs-comfy-icon { display: inline-block; width: 1em; height: 1em; flex: none; }
.rs-view-mark { position: absolute; right: -6px; bottom: -5px; font-size: 8px; width: 13px; height: 13px; display: grid; place-items: center;
border-radius: 50%; background: #71717a; color: #fff; border: 1.5px solid var(--rs-bg); }
.rs-view-app .rs-view-mark { color: var(--rs-accent-fg); background: var(--rs-accent); }
.rs-view-chip { display: inline-flex; align-items: center; gap: 3px; margin-left: 6px; font-size: 10.5px; padding: 0 6px; }
.rs-view-chip .rs-comfy-icon { font-size: 11px; }
.rs-view-chip.rs-view-app { color: var(--rs-accent); border-color: var(--rs-accent); }
.rs-cursor-layer { position: fixed; inset: 0; pointer-events: none; z-index: 900; overflow: hidden; }
.rs-cursor { position: absolute; left: 0; top: 0; will-change: transform; transition: transform 70ms linear; }
.rs-cursor svg { display: block; filter: drop-shadow(0 1px 1px rgb(0 0 0 / .4)); }
+15 -2
View File
@@ -120,7 +120,7 @@ export class RoomSync extends EventTarget {
this.inFlight = 0; // our patches not yet acknowledged
this.crossed = false; // a remote patch arrived while ours were in flight
client.on("welcome", () => { this.targetKey = undefined; this.tick(); });
client.on("welcome", () => { this.targetKey = undefined; this.sentView = undefined; this.tick(); });
client.on("status", (s) => { if (s !== "online") { this.ready = false; this.emitState(); } });
client.on("room", (msg) => this.onRoom(msg));
client.on("room_info", (msg) => {
@@ -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. */
@@ -254,6 +261,9 @@ export class RoomSync extends EventTarget {
tick() {
if (!this.client.online || !this.store) return;
// Tell the others whether this tab shows the node graph or the App view.
const view = this.inAppMode ? "app" : "graph";
if (view !== this.sentView && this.client.send({ type: "view", view })) this.sentView = view;
const { wf, room } = this.activeRoom();
const key = room?.key ?? null;
if (key !== this.targetKey || (key && wf !== this.targetWf)) {
@@ -317,6 +327,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 +336,7 @@ export class RoomSync extends EventTarget {
} finally {
this.ownLoad--;
this.applying--;
if (mode && wf.activeMode !== mode) wf.activeMode = mode;
}
this.settle();
this.updateReadOnly();
+8
View File
@@ -62,3 +62,11 @@ export const storage = {
} catch { /* private mode */ }
},
};
// ComfyUI's own icons for its Graph / App switch, so RigShare matches what people see there.
export const GRAPH_ICON = "icon-[comfy--workflow]";
export const APP_ICON = "icon-[lucide--panels-top-left]";
export function viewIcon(view, cls = "") {
return h("i", { class: `${view === "app" ? APP_ICON : GRAPH_ICON} rs-comfy-icon ${cls}` });
}