Author SHA1 Message Date
Edouard Durand 3c5e8bfa3a RigShare 1.3.2: access checks without a live room, refuse Windows alias paths, same-origin login redirect 2026-10-08 10:57:57 +00:00
Edouard Durand fe45031dcb Merge branch 'master' of https://github.com/xiaodoudou/ComfyUI-RigShare into security-path-hardening 2026-10-08 08:35:57 +00:00
Xiaodoudou 1befdca2e7 RigShare 1.3.1: refuse folder paths outside the workspace (Windows)
* RigShare 1.3.1: refuse folder paths outside the workspace (Windows)

* Tests: escape test no longer assumes Windows path semantics
2026-10-08 16:35:36 +08:00
Edouard Durand 92607ac025 Tests: escape test no longer assumes Windows path semantics 2026-10-08 08:34:21 +00:00
Edouard Durand c6ded00173 RigShare 1.3.1: refuse folder paths outside the workspace (Windows) 2026-10-08 08:33:04 +00:00
Edouard Durand d68307cde4 README: GIF of two people editing one workflow live 2026-10-04 07:58:07 +00:00
Edouard Durand 13b9350a50 RigShare 1.3.0 2026-10-04 06:45:30 +00:00
Edouard Durand 3797fdf57e Performance: yellow timer when the run is the workflow on screen 2026-09-27 08:31:34 +00:00
Edouard Durand 70e0af8493 Changelog: deleted workflows close their tabs 2026-09-27 07:04:01 +00:00
Edouard Durand 77575fa6b2 Close a workflow's tabs when you delete it from the file list 2026-09-27 07:03:48 +00:00
Edouard Durand 69ad03e237 Performance: own run log, History window, timer-only floating card with clear
Runs are timed from ComfyUI's execution events and kept in runs.json (last
500), so ETAs survive restarts and a workflow's timings can be cleared. The
Server tab shows the last 5 runs and a History window; the floating card is
a bigger timer with a clear button, docked to the left of the Server card.
2026-09-27 05:15:18 +00:00
Edouard Durand 4fdbc035f4 Performance: run in progress vs ETA and last runs, in the Server tab and as a floating card
Floating cards share one base (float.js) with magnet docking; docked cards
stack in the corner. Durations come from ComfyUI's history timestamps, the
start of the current run from the execution_start event.
2026-09-27 03:22:51 +00:00
Edouard Durand b9aadf496b Floating monitor: dock beside the minimap and zoom toolbar, bottom-aligned with the toolbar 2026-09-27 03:17:45 +00:00
Edouard Durand 6154ce1a03 Floating monitor: magnet docking and admin memory actions
The magnet docks the monitor bottom-right: beside the minimap in the graph,
in the corner of the App view; dragging undocks it. Admins get Unload models
and Free models & cache. /free now needs admin.
2026-09-27 01:39:34 +00:00
Edouard Durand 02dfd79523 Admin option: ComfyUI Manager in its own sidebar tab
A Manager tab in the left bar opens the Manager menu or the Custom Nodes
Manager, for accounts with the manager permission, and the top-bar button
is hidden. Off by default (manager_tab).
2026-09-27 01:16:47 +00:00
Edouard Durand 27e4fd37f1 Guard other plugins like ComfyUI Manager; hide Pixaroma's workflow browser
Plugin button groups in the top bar are hidden without the manager
permission, and rgthree-comfy / Crystools server-wide settings need it too.
Pixaroma's workflow browser (which lists every folder from disk) is hidden
with the other workflow browsers and its routes are admin-only.
2026-09-27 00:54:21 +00:00
Edouard Durand e123dc9279 RigShare 1.2.3 2026-09-27 00:43:44 +00:00
Edouard Durand a75443d8bb Files tab refreshes when workflows change
The server tells everyone who can open the folder when a workflow is saved,
moved or deleted, or folders are created, renamed, moved, deleted or have
their access changed; the Files tab reloads the folder on screen.
2026-09-27 00:42:03 +00:00
Edouard Durand 6e2324a7c3 Back to the login screen when the session has ended
The ComfyUI page is served with no-store and carries a tiny script, run
before ComfyUI's own code, that goes to the login page when a request is
answered 401 with X-RigShare-Login: required (now sent by the login gate).
2026-09-27 00:39:09 +00:00
Edouard Durand 137051a135 Fix publish workflow YAML (a literal line break in printf) and line endings 2026-09-26 08:36:55 +00:00
Edouard Durand 78415ebf25 Registry changelogs as plain text
The registry shows a version's changelog as one paragraph, without Markdown
or line breaks: a shared script turns the CHANGELOG.md section into
'Added: ...; ... Fixed: ...' for both the publish and the update workflows.
2026-09-26 08:34:19 +00:00
Edouard Durand c94b90133a Workflow to fill in registry changelogs of published versions 2026-09-26 08:30:17 +00:00
Edouard Durand 5373c85b86 RigShare 1.2.2 2026-09-26 08:27:05 +00:00
Edouard Durand 1f38a22535 Queue: filled square stop icon 2026-09-26 08:22:57 +00:00
Edouard Durand 531fec2ec9 Queue tab: running and pending prompts with who queued them
The server records who queued each prompt (and the tab's name, sent by the
client) and publishes queue items with the stats. Cancelling someone else's
prompt or clearing the queue needs Admin. Tabs: Live, Chat, People, Queue,
Server, Admin.
2026-09-26 08:20:44 +00:00
Edouard Durand 91f3383e86 Registry: send the changelog, fix the logo, fuller description
The publish workflow passes the version's CHANGELOG.md section to comfy-cli
(COMFY_NODE_CHANGELOG), icon.png is re-rendered from icon.svg at 512x512
with all four corners rounded, and the description covers the features.
2026-09-26 07:25:07 +00:00
Edouard Durand 2b1b3b11ce Admin: clear the chat history
DELETE /rigshare/api/chat (admin) empties chat.jsonl, tells every client to
clear its history, then posts a notice saying who cleared it. Admin tab gets
a Chat section with a confirmed Clear chat history button.
2026-09-26 07:21:58 +00:00
Edouard Durand 7e10db82df App mode: keep an open RigShare or Files panel when entering the App view
ComfyUI closes the side panel when a tab enters the App view; reopen the
RigShare tab that was open, so it behaves the same in both directions.
2026-09-26 07:17:49 +00:00
Edouard Durand 5e943f3e60 RigShare 1.2.1 2026-09-26 07:09:33 +00:00
Edouard Durand f4131ac6f5 Fix blank RigShare/Files panel after switching between App view and graph
ComfyUI keeps a side panel per view and shows the old one again without
asking the tab to render; the tabs now follow whichever panel is visible.
2026-09-26 07:07:40 +00:00
Edouard Durand d3cef4a6ca App mode: open RigShare and Files in ComfyUI's docked side panel
The App view's side panel shows whichever sidebar tab is active, so our
sidebar buttons now activate the real RigShare and Files tabs instead of
opening a floating drawer.
2026-09-26 07:05:00 +00:00
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
Edouard Durand 856208dedc RigShare 1.1.0: README, Files guide, upgrade notes, changelog, screenshots 2026-09-26 03:28:47 +00:00
Edouard Durand 3f1ddfed7f Whole chat history with infinite scroll back; fix switching between sidebar tabs
Chat is an append-only chat.jsonl paged by sequence number: the welcome
carries the latest 50 messages and GET /rigshare/api/chat pages further back.
The panel loads older pages as you scroll up, keeps your place, and adds date
dividers. ComfyUI reuses one slot element between custom sidebar tabs, so
each tab now replaces the slot's content instead of appending to it.
2026-09-26 03:09:24 +00:00
Edouard Durand a97ade0974 Files tabs (My files, Shared, All); access follows folders only
The Files browser gets tabs instead of a Home screen, also in the Open, Save
and Move dialogs; All (the whole workflows folder) is for admins. Per-workflow
access lists are removed: a live workflow's access is its folder's, and This
tab offers Folder access to the folder's owner.
2026-09-26 02:52:04 +00:00
Edouard Durand d2e4ba62b1 Open tabs follow renamed and moved workflows
The server tells everyone who could open the old path where a file or folder
went (checked before folder settings change, so restricted names don't leak),
and clients re-point their tabs through ComfyUI's own rename without a second
server move. Tabs of files moved out of reach stay as private copies.
2026-09-26 02:46:43 +00:00
Edouard Durand e1d103564b Sharing follows folders: workflows outside private folders are live while open
Replaces Share this tab / Stop sharing and the auto-share settings. The Live
list shows workflows someone has open, the Files tab marks live files,
private tabs offer Move to Shared, and deleting or moving a live file into a
private folder closes its room on the server.
2026-09-26 02:42:07 +00:00
Doudou 7a5385c46a Test suite: pytest unit and integration tests, node tests, CI workflow 2026-09-26 02:17:22 +00:00
Doudou 82b12c360d Files: Drive-style sidebar tab (My files / Shared), Open from RigShare, folder browser in Save, folder operations 2026-09-26 02:10:39 +00:00
Doudou e3becf8a7f Workspace: private folder per account, owner-managed shared folders, Save asks where, Files tab 2026-09-26 02:01:50 +00:00
Doudou 4b6c588806 Fix: opening a file could overwrite the shared workflow of the previous tab; stop auto-sharing by default 2026-09-26 01:53:35 +00:00
Doudou c779c5973b Set Comfy Registry publisher 2026-09-25 13:33:49 +00:00
Doudou ee8ba9837b Registry publishing: .comfyignore, workflow on master 2026-09-25 13:33:11 +00:00
64 changed files with 5619 additions and 559 deletions
+5
View File
@@ -0,0 +1,5 @@
# Kept in git, left out of the Comfy Registry package
docs/
.github/
.gitattributes
tests/
+74
View File
@@ -0,0 +1,74 @@
"""Print a version's CHANGELOG.md section as plain text for the Comfy Registry.
The registry shows a version's changelog as one paragraph: line breaks and
Markdown are not rendered. So headings become "Added:", "Fixed:"..., list
items are joined with "; ", and Markdown markup is dropped.
python .github/scripts/registry_changelog.py 1.2.2
python .github/scripts/registry_changelog.py --all # JSON {version: text}
"""
import json
import re
import sys
def sections(path="CHANGELOG.md"):
out, current = {}, None
for line in open(path, encoding="utf-8").read().splitlines():
m = re.match(r"^## (\d+\.\d+\.\d+)\b", line)
if m:
current = m.group(1)
out[current] = []
elif line.startswith("## "):
current = None
elif current:
out[current].append(line)
return out
def plain(text):
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) # links: keep the text
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
text = re.sub(r"(?<!\w)\*([^*]+)\*(?!\w)", r"\1", text)
return text.replace("`", "").strip()
def registry_text(lines):
parts, heading, items, intro = [], None, [], []
def flush():
if items:
body = "; ".join(i.rstrip(".") for i in items) + "."
parts.append(f"{heading}: {body}" if heading else body)
for raw in lines:
line = raw.strip()
if not line:
continue
if line.startswith("### "):
flush()
heading, items = plain(line[4:]), []
elif line.startswith("- "):
items.append(plain(line[2:]))
elif items:
items[-1] += " " + plain(line) # a wrapped list item
else:
intro.append(plain(line))
flush()
return " ".join(intro + parts).strip()
def main():
sys.stdout.reconfigure(encoding="utf-8")
all_sections = sections()
if sys.argv[1:] == ["--all"]:
print(json.dumps({v: registry_text(lines) for v, lines in all_sections.items()}, ensure_ascii=False))
elif len(sys.argv) == 2:
print(registry_text(all_sections.get(sys.argv[1], [])))
else:
sys.exit(__doc__)
if __name__ == "__main__":
main()
+20 -2
View File
@@ -3,7 +3,7 @@ on:
workflow_dispatch:
push:
branches:
- main
- master
paths:
- "pyproject.toml"
@@ -13,8 +13,26 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Changelog for this version
# comfy-cli sends COMFY_NODE_CHANGELOG as the version's changelog (the
# registry's Updates section), as plain text: the registry shows one paragraph.
run: |
version=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml | head -n1)
notes=$(python3 .github/scripts/registry_changelog.py "$version")
if [ -z "$notes" ]; then
echo "::warning::No CHANGELOG.md section for $version"
exit 0
fi
delimiter="EOF_$(date +%s%N)"
{
echo "COMFY_NODE_CHANGELOG<<$delimiter"
echo "$notes"
echo "$delimiter"
} >> "$GITHUB_ENV"
echo "Changelog for $version: $notes"
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
with:
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} ## Add your own personal access token to your Github Repository secrets and reference it here.
skip_checkout: "true"
+57
View File
@@ -0,0 +1,57 @@
name: Update registry changelogs
# Fills in the Comfy Registry changelog of versions already published, from the
# matching CHANGELOG.md sections. Run it by hand (Actions -> Run workflow).
on:
workflow_dispatch:
inputs:
versions:
description: "Versions to update, space separated (empty: every version in CHANGELOG.md)"
required: false
default: ""
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Update changelogs
env:
# The registry's edit endpoint takes a registry account key; the publishing
# token is tried when no REGISTRY_UPDATE_TOKEN secret is set.
TOKEN: ${{ secrets.REGISTRY_UPDATE_TOKEN || secrets.REGISTRY_ACCESS_TOKEN }}
VERSIONS: ${{ inputs.versions }}
run: |
python3 - <<'PY'
import json, os, re, sys, urllib.error, urllib.request
API = "https://api.comfy.org"
publisher = re.search(r'^PublisherId\s*=\s*"([^"]+)"', open("pyproject.toml").read(), re.M).group(1)
node = re.search(r'^name\s*=\s*"([^"]+)"', open("pyproject.toml").read(), re.M).group(1)
import subprocess
# Plain text: the registry shows the changelog as one paragraph.
notes = json.loads(subprocess.check_output(
[sys.executable, ".github/scripts/registry_changelog.py", "--all"], text=True, encoding="utf-8"))
with urllib.request.urlopen(f"{API}/nodes/{node}/versions") as r:
published = json.load(r)
ids = {v["version"]: v["id"] for v in published}
wanted = os.environ.get("VERSIONS", "").split() or sorted(notes)
failed = False
for version in wanted:
if version not in ids or not notes.get(version):
print(f"skip {version}: {'not published' if version not in ids else 'no CHANGELOG section'}")
continue
req = urllib.request.Request(
f"{API}/publishers/{publisher}/nodes/{node}/versions/{ids[version]}",
data=json.dumps({"changelog": notes[version]}).encode(), method="PUT",
headers={"Content-Type": "application/json", "Authorization": f"Bearer {os.environ['TOKEN']}"})
try:
with urllib.request.urlopen(req) as r:
print(f"updated {version}: HTTP {r.status}")
except urllib.error.HTTPError as e:
failed = True
print(f"::error::{version}: HTTP {e.code} {e.read()[:300]!r}")
sys.exit(1 if failed else 0)
PY
+23
View File
@@ -0,0 +1,23 @@
name: Tests
on:
push:
pull_request:
jobs:
test:
name: Python and JavaScript tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- uses: actions/setup-node@v5
with:
node-version: "22"
- name: Install test dependencies
run: pip install pytest aiohttp
- name: Python tests
run: python -m pytest -q
- name: JavaScript tests
run: node --test "tests/js/*.test.mjs"
+142
View File
@@ -1,5 +1,147 @@
# Changelog
## 1.3.2 (2026-10-08)
Access rules tightened, following the review of the ComfyUI Manager listing.
### Fixed
- Snapshots of a workflow could be listed and read by an account with no access to its folder whenever nobody had that workflow open. Snapshot access now follows the workflow's folder (private and restricted shared folders included), open or not
- Joining a live workflow seeded a new room from the caller's document before the access check. Access is checked first, and only someone who can edit the workflow can open a new room
- Windows path segments that end in a dot or space, or use an 8.3 short name, are now refused instead of being silently dropped, so a path can no longer be classified as a different folder than the one Windows opens
- After signing in, the login page only follows its `next` redirect when it resolves to the same origin
## 1.3.1 (2026-10-08)
Security hardening of the workspace folder routes, mainly for Windows servers.
### Fixed
- Folder rename, move, delete and create (and the workspace listing) could be pointed outside the workflows folder with a Windows drive prefix (`C:`), which let `rmtree` / `rename` act on folders such as models or custom_nodes. Every path is now checked before it touches the disk: segments with a colon, backslash tricks or `..` are refused, and the resolved path must stay inside the workflows folder
- On Windows, case and aliases of a path were not seen by the access checks: `Users/alice`, `users./alice` or `USERS~1/alice` opened Alice's private folder as if it were common, and `Shared/<restricted folder>` skipped its access list. Paths are now matched to the name on disk and trailing dots, spaces and 8.3 short names are dropped before any permission check
- New usernames and folder names can no longer end in a dot or space, start with a dot, or be a Windows device name (`CON`, `NUL`, `COM1`...). Usernames that differ only by case count as the same name, since they share one folder on Windows. Existing accounts and folders are untouched
### Added
- Tests for the JWT module, the server stats, and the folder routes refusing paths outside the workspace
## 1.3.0 (2026-10-04)
A Performance panel, a floating monitor that docks, and Manager in its own tab.
### Added
- **Performance** in the Server tab: the run in progress against an ETA (the average of the same workflow's last 5 successful runs), the last 5 runs with who launched them and how long they took, and a **History** window with every recorded run. Timings come from ComfyUI's own timestamps and are kept in `runs.json`, so they survive restarts
- Performance pops out as a floating card: just the timer, big, with the usual time of the workflow on screen when idle, and a button to clear that workflow's timings (after a confirmation; needs *Edit*). Docked cards sit side by side in the corner, Performance to the left of Server
- Floating server monitor: a magnet docks it in the bottom-right corner and keeps it there, beside the minimap in the graph, in the corner of the App view. Dragging it undocks it
- Floating server monitor: **Unload models** and **Free models & cache** buttons for admins, like ComfyUI Manager's
- Admin option *ComfyUI Manager in its own sidebar tab*: a **Manager** tab in the left bar (Open Manager, Custom Nodes Manager) replaces the top-bar button, for accounts with the *Manager* permission
### Changed
- Freeing models and cache (ComfyUI's `/free`) needs *Admin* instead of *Queue*: it affects everyone's runs
- Other plugins' top-bar buttons (rgthree-comfy, Crystools...) are hidden without the *Manager* permission, like ComfyUI Manager's. Their settings kept on the server, which apply to everyone, need *Manager* too
- Pixaroma's Workflows panel is hidden along with ComfyUI's Workflows and Apps tabs (admin setting now called *Hide other workflow browsers*), and its shortcut opens Files. Its server routes are refused to everyone but admins: they read the workflows folder straight from disk, private folders included
### Fixed
- Deleting a workflow (or a folder) from the Files tab now closes its open tabs; before, the tab stayed open as an orphan
## 1.2.3 (2026-09-27)
### Fixed
- The Files tab didn't show a workflow you had just saved into the folder on screen until you left the folder and came back. It now refreshes whenever workflows are saved, moved, renamed or deleted, or folders change, including by other people (each person is only told about folders they can open)
- When the session had ended (logged out elsewhere, expired, cookie cleared), a ComfyUI page that was already open showed "API key missing" errors instead of going back to the login screen. It now returns to the login screen as soon as a request says login is required, even if ComfyUI had not finished loading
## 1.2.2 (2026-09-26)
A Queue tab showing who launched what, and chat clean-up for admins.
### Added
- **Queue** tab: the run in progress and the ones waiting, each with the workflow's name, who queued it and when. Stop or remove your own runs from there; admins can do it for anyone's. The server enforces it too: stopping or removing someone else's prompt, or clearing the whole queue, needs *Admin*
- Admins can clear the whole chat history (*Admin → Chat*, or `DELETE /rigshare/api/chat`). Everyone's chat empties at once, followed by a notice saying who cleared it
### Changed
- Panel tabs in a new order: Live, Chat, People, Queue, Server, Admin
- A fuller description on the Comfy Registry
### Fixed
- Entering the App view closed an open RigShare or Files panel (going back to the graph kept it open). It now stays open both ways
- The Comfy Registry showed no changelog: the publish workflow now sends the matching `CHANGELOG.md` section
- The registry logo was a clipped render with only one rounded corner
## 1.2.1 (2026-09-26)
### Changed
- App mode: the **RigShare** and **Files** buttons open their tabs in ComfyUI's docked side panel, like Assets and Apps, instead of a floating drawer
### Fixed
- Switching between the App view and the graph with the RigShare or Files panel open left the panel blank
## 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.
### Added
- **Workspace folders**: a private **My files** folder per account (`users/<name>/`), **Shared** folders owned by whoever creates them, and common workflows at the top level. The server enforces them on ComfyUI's own file API, and ComfyUI's workflow browser lists only what you can open
- **Files** sidebar tab with **My files**, **Shared** and, for admins, **All** (the whole workflows folder); new folders, rename, move and delete; a green dot on live files; **From computer**
- **Save asks where**: a name plus the same folder tabs, starting in My files or the last folder used
- **Open from RigShare…** in the Workflow menu (Ctrl+Alt+O)
- Folder access lists: a shared folder's owner or an admin can restrict it to chosen people, each with view or edit. For a live workflow in a folder you own, *This tab* shows **Folder access**
- Only admins delete common workflows; a shared folder's owner deletes in that folder; you delete in yours
- Whole chat history: stored in `chat.jsonl` with no limit (`chat_keep` trims it). The panel shows the latest 50 messages and loads older ones as you scroll up, with date dividers. The old `chat.json` is imported once
- Test suite: pytest unit and end-to-end tests against a real aiohttp server, Node tests for the client, and a GitHub Actions workflow
### Changed
- Sharing follows folders: every saved workflow outside a private folder is live while it is open, with nothing to share by hand. Unsaved tabs and files in My files stay private
- The **Live** tab lists the workflows someone has open right now, with their folder and who is in them. A private workflow's panel offers **Move to Shared…**
- Renaming or moving a workflow or folder keeps everyone's open tabs on it, under the new name. People who can't open the new location learn nothing about it and keep a private copy
- Deleting a live workflow closes it for the people who have it open, keeping a snapshot; moving one into a private folder takes it out of live sharing
- Who can open a workflow follows its folder only
### Fixed
- Opening a file could overwrite the shared workflow of the previous tab
- Switching straight between the RigShare and Files sidebar tabs could leave the previous tab's content on screen
### Removed
- Per-workflow access lists (restrict the folder instead). Lists set on individual workflows are dropped
- *Share this tab*, *Stop sharing*, the automatic-sharing settings and the admin 🗑 for idle rooms
- Live rooms for unsaved tabs (`tmp:`): save the workflow outside My files to work on it together
## 1.0.0
First release of RigShare, a rewrite of [ComfyUI-Nexus](https://github.com/daxcay/ComfyUI-Nexus) 1.0.2 for the current ComfyUI frontend.
+41 -28
View File
@@ -4,31 +4,43 @@
# ComfyUI RigShare
> One ComfyUI rig, a whole team: accounts, live co-editing of every workflow, chat and a GPU monitor
> One ComfyUI rig, a whole team: accounts, shared folders, live co-editing, chat and a GPU monitor
[![GitHub Release](https://img.shields.io/github/v/release/xiaodoudou/ComfyUI-RigShare?style=flat-square&color=e94560)](https://github.com/xiaodoudou/ComfyUI-RigShare/releases/latest) [![Stars](https://img.shields.io/github/stars/xiaodoudou/ComfyUI-RigShare?style=flat-square&color=yellow)](https://github.com/xiaodoudou/ComfyUI-RigShare/stargazers) [![License](https://img.shields.io/badge/License-MIT-blue?style=flat-square)](LICENSE) [![ComfyUI](https://img.shields.io/badge/ComfyUI%20frontend-1.53%2B-7c3aed?style=flat-square)](docs/install.md) [![Python](https://img.shields.io/badge/Python-3.10%2B-3776ab?style=flat-square)](docs/install.md) [![Dependencies](https://img.shields.io/badge/Dependencies-none-brightgreen?style=flat-square)](docs/install.md) [![Auth](https://img.shields.io/badge/Auth-JWT%20%7C%20API%20keys-orange?style=flat-square)](docs/security.md)
A custom node that turns a single ComfyUI server into a shared workspace. Everyone signs in, opens any workflow, and edits it together in real time while seeing each other's cursors. Fork of
A custom node that turns a single ComfyUI server into a shared workspace. Everyone signs in, keeps their own workflows in a private folder, and edits the shared ones together in real time while seeing each other's cursors. Fork of
[daxcay/ComfyUI-Nexus](https://github.com/daxcay/ComfyUI-Nexus), rewritten for the current ComfyUI frontend.
</div>
---
<div align="center">
<img src="docs/images/live-cursors.gif" width="900" alt="Two people editing the same workflow at once, each seeing the other's named cursor and selection">
<sub>Two accounts, one workflow: each person sees the other's cursor, selection and edits live.</sub>
</div>
## What it does
- asks everyone to sign in before ComfyUI loads, and creates the admin account on first run
- shares every workflow: people who open the same saved file edit it together, and unsaved tabs join as soon as you edit them
- lets you switch tabs freely, because only the tab on screen syncs
- organises workflows like a shared drive: a private **My files** folder per account, **Shared** folders, and an **All** view for admins, in a Files tab of its own
- asks where to save, with the same folders, and opens workflows from them (Ctrl+Alt+O)
- makes every workflow outside a private folder live while it is open: people who open the same file edit it together, with nothing to share by hand
- lets folder owners restrict a shared folder to chosen people, each able to view or edit
- lets you switch tabs freely, because only the tab on screen syncs, and keeps open tabs pointed at a workflow when someone renames or moves it
- 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 a chat with an unread badge, a blinking icon and a sound
- keeps per-workflow history: snapshots every few minutes, named snapshots, one-click restore
- keeps the whole chat history, loading older messages as you scroll up, with an unread badge, a blinking icon and a sound; admins can clear it
- shows the queue: what is running and waiting, and who launched each run, with a button to stop or remove your own
- 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
- lets you restrict a workflow to chosen people, each able to view or edit
- 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
- shows CPU, RAM, and for each GPU the load, VRAM, temperature and power, plus the queue, in a panel tab or a floating widget
- shows CPU, RAM, and for each GPU the load, VRAM, temperature and power, in a panel tab or a floating widget
- sends execution progress and previews to everyone, not only whoever queued
What this fork adds over Nexus:
@@ -36,28 +48,37 @@ What this fork adds over Nexus:
- **Built for the new frontend.** It lives in a sidebar tab and works with workflow tabs and subgraphs. It needs no legacy menu and no litegraph patching.
- **Real accounts.** A login page, hashed passwords, JWT sessions and API keys replace the shared `/login account password` chat command and the plaintext `admins.json`.
- **The server is in charge.** It checks permissions and identity on every message, so a browser cannot impersonate someone or edit without the right to.
- **Every workflow is shared**, not a single canvas that everybody fights over.
- **Folders, not one canvas.** Everyone has their own space, shared work lives in shared folders, and nobody fights over a single canvas.
## Screenshots
<table>
<tr>
<td align="center"><img src="docs/images/history.png" width="260" alt="Workflows tab: this tab, history and shared workflows"><br><sub>Shared workflows and history</sub></td>
<td align="center"><img src="docs/images/people.png" width="260" alt="People tab"><br><sub>Who is online, and on which workflow</sub></td>
<td align="center"><img src="docs/images/chat.png" width="260" alt="Chat tab"><br><sub>Chat</sub></td>
<td align="center"><img src="docs/images/history.png" width="260" alt="Live tab: the workflow on screen, its history and the live workflows"><br><sub>Live workflows and history</sub></td>
<td align="center"><img src="docs/images/files.png" width="260" alt="Files tab: My files, Shared and All"><br><sub>Files: My files, Shared, All</sub></td>
<td align="center"><img src="docs/images/files-shared.png" width="260" alt="Shared folders in the Files tab"><br><sub>Shared folders, live files marked</sub></td>
</tr>
<tr>
<td align="center"><img src="docs/images/people.png" width="260" alt="People tab"><br><sub>Who is online, and on which workflow</sub></td>
<td align="center"><img src="docs/images/chat.png" width="260" alt="Chat tab"><br><sub>Chat, with the whole history</sub></td>
<td align="center"><img src="docs/images/access.png" width="260" alt="Access list of a shared folder"><br><sub>Who can open a folder</sub></td>
</tr>
<tr>
<td align="center"><img src="docs/images/queue.png" width="260" alt="Queue tab"><br><sub>Queue: who launched what</sub></td>
<td align="center"><img src="docs/images/server.png" width="260" alt="Server tab"><br><sub>Server monitor</sub></td>
<td align="center"><img src="docs/images/account.png" width="260" alt="Account and API keys"><br><sub>Account and API keys</sub></td>
<td align="center"><img src="docs/images/admin.png" width="260" alt="Admin tab"><br><sub>Accounts and server settings</sub></td>
</tr>
<tr>
<td align="center"><img src="docs/images/access.png" width="260" alt="Access list of a workflow"><br><sub>Who can open a workflow</sub></td>
<td align="center"><img src="docs/images/admin.png" width="260" alt="Admin tab"><br><sub>Accounts and server settings</sub></td>
<td></td>
<td></td>
</tr>
</table>
<p align="center">
<img src="docs/images/save.png" width="820" alt="Save dialog: name and location"><br><sub>Save asks where</sub>
</p>
<p align="center">
<img src="docs/images/floating.png" width="820" alt="Floating server monitor over the canvas"><br><sub>Floating server monitor</sub>
</p>
@@ -77,6 +98,8 @@ git clone https://github.com/xiaodoudou/ComfyUI-RigShare.git
Open ComfyUI in a browser. There are no accounts yet, so the login page asks you to create the administrator. Sign in, open the **RigShare** tab in the sidebar and add your team under **Admin → Accounts**. That is the whole setup.
Each person gets a private **My files** folder. Put what you work on together in **Shared**, in the **Files** tab: it goes live for everyone who opens it.
Everyone then signs in at the same address. To use the API from a script, create a key under your avatar → **API keys**:
```bash
@@ -89,13 +112,15 @@ Behind a reverse proxy, turn on WebSocket support, or nothing will be live. See
| | |
|---|---|
| [Installing](docs/install.md) | Custom node, Docker/compose, first-run setup, upgrading from Nexus |
| [Sharing workflows](docs/sharing.md) | Rooms, saved vs unsaved tabs, stop sharing, snapshots, follow |
| [Installing](docs/install.md) | Custom node, Docker/compose, first-run setup, upgrading from 1.0 or Nexus |
| [Files and folders](docs/files.md) | My files, Shared, All, saving, opening, who can do what |
| [Sharing workflows](docs/sharing.md) | Live workflows by folder, folder access, snapshots, follow |
| [Accounts and permissions](docs/accounts.md) | Edit, Queue, Admin, renaming, what viewers can do |
| [API access](docs/api.md) | API keys, websockets, trusted IPs, other apps such as Open WebUI |
| [Settings](docs/settings.md) | Panel settings, ComfyUI settings, every key in `config.json` |
| [Reverse proxy](docs/reverse-proxy.md) | Nginx Proxy Manager, plain nginx, HTTPS |
| [Security](docs/security.md) | How passwords, sessions and keys are protected |
| [Development](docs/development.md) | Running the tests, code layout |
| [Changelog](CHANGELOG.md) | What changed, and when |
## Why this exists
@@ -105,15 +130,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] Every workflow shared, per-workflow snapshots
- [x] Cursors, selections, follow mode
- [x] Chat with badge, blink and sound
- [x] Server monitor with floating widget
- [x] Conflict-free node ids when two people add nodes at the same instant
- [x] Per-workflow access lists (share with specific people)
- [ ] Publish to the Comfy registry
+22 -8
View File
@@ -1,28 +1,42 @@
import logging
import os
import folder_paths
from server import PromptServer
from .rigshare_core.store import Store
from .rigshare_core.hub import Hub
from .rigshare_core import routes
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
WEB_DIRECTORY = "./web"
try:
import folder_paths
from server import PromptServer
except ImportError: # imported outside ComfyUI, e.g. by the test runner
folder_paths = PromptServer = None
def data_folder():
return os.environ.get("RIGSHARE_DATA_DIR") or os.path.join(folder_paths.base_path, "rigshare")
if hasattr(PromptServer, "instance"):
def workflows_dir():
"""ComfyUI's workflows folder for the (single) default user."""
try:
user_root = folder_paths.get_public_user_directory("default")
except Exception:
user_root = None
return os.path.join(user_root or os.path.join(folder_paths.get_user_directory(), "default"), "workflows")
if PromptServer is not None and hasattr(PromptServer, "instance"):
try:
from .rigshare_core.store import Store
from .rigshare_core.hub import Hub
from .rigshare_core.workspace import Workspace
from .rigshare_core import routes
folder = data_folder()
os.makedirs(folder, exist_ok=True)
store = Store(folder)
hub = Hub(store, PromptServer.instance)
hub.workspace = Workspace(store, workflows_dir())
routes.setup(PromptServer.instance, store, hub)
logging.info(f"[RigShare] ready, data in {folder}")
except Exception as e:
+4 -4
View File
@@ -6,9 +6,9 @@
|---|---|
| *(none)*, a viewer | Watch shared workflows, chat, follow people, open snapshots as private copies |
| **Edit** | Change shared workflows, save or rename workflow files, take and restore snapshots, upload files |
| **Queue** | Queue prompts, interrupt, free memory |
| **Manager** | Open ComfyUI Manager: install, update or remove custom nodes and models, snapshots, restart |
| **Admin** | Everything, plus deleting workflow files, removing shared workflows, accounts and server settings |
| **Queue** | Queue prompts, stop or remove your own runs. Stopping or removing someone else's, or clearing the queue, needs *Admin* |
| **Manager** | Open ComfyUI Manager: install, update or remove custom nodes and models, snapshots, restart. Also other plugins' top-bar buttons and their server-wide settings (rgthree-comfy, Crystools) |
| **Admin** | Everything, plus deleting workflow files, freeing models and cache, accounts and server settings |
Queueing, uploads, and saving or deleting workflow files are checked by the server too, when *Enforce permissions on the ComfyUI API* is on (the default). A viewer cannot get around it with a script.
@@ -25,7 +25,7 @@ Admins use **Admin → Accounts** in the RigShare panel:
Admins can also toggle Edit, Queue and Manager straight from the **People** tab.
A shared workflow can also be restricted to chosen people, with *Can view* or *Can edit* each. See [Who can open it](sharing.md#who-can-open-it).
A shared folder can be restricted to chosen people, with *Can view* or *Can edit* each; its workflows follow it. See [Who can open it](sharing.md#who-can-open-it).
The last admin cannot be demoted or deleted.
+5 -2
View File
@@ -46,10 +46,13 @@ The panel uses these; they take the same session cookie or API key.
| `GET /rigshare/api/status` | Server name and whether setup is needed (public) |
| `GET /rigshare/api/me` | The current account |
| `GET/POST /rigshare/api/me/keys`, `DELETE /rigshare/api/me/keys/{id}` | Your API keys |
| `GET /rigshare/api/rooms` | Shared workflows |
| `GET /rigshare/api/chat?before=<seq>&limit=50` | Older chat messages, oldest first, and whether more exist |
| `DELETE /rigshare/api/chat` | Clear the whole chat history (admin) |
| `GET /rigshare/api/performance/history?limit=100&workflow=` | Recorded runs, newest first: duration, result, workflow, who |
| `DELETE /rigshare/api/performance?workflow=` | Forget a workflow's timings, so its ETA starts over (*Edit*) |
| `GET /rigshare/api/rooms` | Live workflows |
| `GET /rigshare/api/room?key=…` | One room, with its workflow JSON |
| `GET /rigshare/api/snapshots?room=…` | History of a room |
| `GET/PUT /rigshare/api/room/acl?key=…` | Who can open a room (owner or admin) |
| `GET /rigshare/api/people` | Accounts that can be added to an access list |
| `GET/POST/PATCH/DELETE /rigshare/api/users…` | Accounts (admin) |
| `GET/PATCH /rigshare/api/config` | Server settings (admin) |
+38
View File
@@ -0,0 +1,38 @@
# Development
## Tests
RigShare has two test suites. Neither needs ComfyUI.
```bash
pip install pytest aiohttp
python -m pytest -q
node --test "tests/js/*.test.mjs"
```
| Suite | What it covers |
|---|---|
| `tests/python/test_store.py` | Accounts, first-run setup, password hashing, JWT sessions (tampering, `alg: none`, expiry, refresh, revocation), API keys, snapshots |
| `tests/python/test_graphdoc.py` | Workflow patches, link repair, id renumbering for simultaneous edits, and parity with the JavaScript implementation |
| `tests/python/test_workspace.py` | Folder rules: path normalisation (double encoding, `../`), private and shared folders, access lists, moves, deletes, browsing |
| `tests/python/test_server.py` | End to end against a real aiohttp server: login gate, cookies, API keys, permissions, Manager gating, template filtering, live rooms, folder access, moves, folders through ComfyUI's userdata API |
| `tests/js/graphdoc.test.mjs` | The client-side patch logic |
| `tests/js/files.test.mjs` | Files tabs (My files, Shared, All), start folders, breadcrumbs |
| `tests/js/sync.test.mjs` | Client diffs, and the regression test for opening a file overwriting the previous tab's shared workflow |
The server tests use a small stand-in for ComfyUI's `/userdata` API (`tests/python/helpers.py`), so they run anywhere Python does. GitHub Actions runs both suites on every push and pull request.
`tests/` is excluded from the Comfy Registry package by `.comfyignore`.
## Layout
| Path | |
|---|---|
| `__init__.py` | ComfyUI entry point: wires the store, hub, workspace and routes |
| `rigshare_core/store.py` | Accounts, sessions, API keys, config, rooms and snapshots on disk |
| `rigshare_core/jwt.py` | HS256 tokens |
| `rigshare_core/hub.py` | Websocket hub: presence, chat, cursors, live rooms |
| `rigshare_core/graphdoc.py` | Workflow patches (mirrored by `web/rigshare/graphdoc.js`) |
| `rigshare_core/workspace.py` | Private and shared folders |
| `rigshare_core/routes.py` | HTTP API and the middleware that enforces login and permissions |
| `web/rigshare/` | The frontend extension |
+60
View File
@@ -0,0 +1,60 @@
# Files and folders
RigShare organises ComfyUI's workflows folder like a shared drive. Where a workflow is saved decides who can open it and whether it is live. See [Sharing workflows](sharing.md) for the live part.
## The folders
| Folder | On disk (`user/default/workflows/`) | Who can open it |
|---|---|---|
| **My files** | `users/<you>/` | You, plus admins. Created when you first sign in. |
| **Shared folders** | `shared/<name>/` | Everyone, or only the people the folder's owner chooses. |
| **Common workflows** | anything else at the top level | Everyone, with their account permissions. |
- Workflows in My files are never live. Everything else is live while someone has it open.
- Renaming an account renames its private folder too, and open tabs follow.
## The Files tab
**Files** has its own icon in the sidebar, with three tabs:
- **My files**: your private folder.
- **Shared**: the shared folders plus the common workflows at the top level.
- **All** (admins only): the whole workflows folder, including everyone's private folders.
<table>
<tr>
<td align="center"><img src="images/files.png" width="240" alt="My files"><br><sub>My files</sub></td>
<td align="center"><img src="images/files-shared.png" width="240" alt="Shared"><br><sub>Shared</sub></td>
<td align="center"><img src="images/files-all.png" width="240" alt="All, for admins"><br><sub>All (admins)</sub></td>
</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**. 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. Every save also keeps a snapshot in the workflow's history.
## Opening
- **Workflow → Open from RigShare…** (Ctrl+Alt+O) browses the same folders and opens a workflow.
- ComfyUI's own workflow browser still works. It lists only the workflows you can open.
## Who can do what
| | My files | Shared folder | Common |
|---|---|---|---|
| Open | owner, admins | everyone, or the folder's list | everyone |
| Save, rename | owner, admins | *Edit* permission and not *Can view* on the list | *Edit* permission |
| Delete, move out | owner, admins | the folder's owner, admins | admins |
- A shared folder's owner is whoever created it. The owner and admins can restrict it to chosen people, each with **Can view** or **Can edit**, and rename or delete it.
- The server checks every rule on ComfyUI's own file API, not just in the panel, so a script or another browser cannot get around them.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

+12 -2
View File
@@ -49,12 +49,22 @@ The data folder is `<ComfyUI base path>/rigshare`; point it elsewhere with `RIGS
| `sessions.json` | Active logins (session ids, not tokens) |
| `secret.key` | Key that signs session tokens. Keep it private |
| `config.json` | Server settings, see [Settings](settings.md) |
| `rooms/` | Current state of every shared workflow |
| `folders.json` | Shared folders: owners and access lists |
| `rooms/` | Latest live state of each workflow |
| `snapshots/` | Workflow history |
| `chat.json` | Recent chat |
| `chat.jsonl` | The whole chat history, one message per line |
| `runs.json` | Timings of the last 500 runs (Performance) |
Back up the folder to keep accounts and history. Deleting `secret.key` signs everyone out.
## Upgrading from RigShare 1.0
Update the folder (`git pull`, or ComfyUI Manager) and restart ComfyUI. Then:
- Workflows already at the top level become *common*: everyone can open them, and they are live while open. Move personal ones into each person's **My files**, and group shared ones in **Shared** folders. Admins can do this for everyone from **Files → All**.
- Access lists set on individual workflows are dropped. Restrict a shared folder instead.
- The chat history moves from `chat.json` to `chat.jsonl` on the first start, with nothing lost. From then on, all of it is kept.
## Upgrading from ComfyUI-Nexus
Remove or disable `custom_nodes/ComfyUI-Nexus` and install RigShare. Nexus kept plaintext passwords in `nexus/admins.json`, so nothing is imported: create the admin on first run, then delete the old `nexus` folder.
+1 -1
View File
@@ -29,7 +29,7 @@ What RigShare does to keep the rig yours, and what it leaves to you.
- ComfyUI's queue, interrupt, free and upload endpoints are checked server-side, and so are saving (Edit) and deleting (Admin) workflow files (`api_protection`).
- Without *Admin*, account and server settings are refused.
- Without *Manager*, ComfyUI Manager's API is refused and its button hidden. Only a few read-only lookups the frontend makes for everyone stay open.
- A restricted workflow is refused to anyone not on its list: joining it, reading it, its snapshots, and its file through ComfyUI's API.
- A workflow in a restricted folder is refused to anyone not on the folder's list: joining it, reading it, its snapshots, and its file through ComfyUI's API.
## What is up to you
+6 -6
View File
@@ -6,14 +6,12 @@ In ComfyUI's settings dialog, under **RigShare**. They apply to your browser onl
| Setting | Default |
|---|---|
| Share saved workflows automatically | on |
| Share unsaved tabs automatically once edited | on |
| Show other people's cursors and selections | on |
| Show names next to cursors | on |
| Play a sound for new chat messages | on |
| Show a notification for new chat messages | off |
Commands, bindable under *Keybindings*: **RigShare: Open chat** and **RigShare: Share this tab**.
Commands, bindable under *Keybindings*: **RigShare: Open chat** and **Open from RigShare…** (Ctrl+Alt+O).
The **Server** tab's *Pop out* button toggles the floating monitor. Drag it by its title; its position is remembered.
@@ -29,13 +27,15 @@ 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 other workflow browsers: ComfyUI's Workflows and Apps sidebar tabs and Pixaroma's Workflows panel. RigShare's Files tab replaces them and their shortcuts open it |
| `manager_tab` | `false` | Show ComfyUI Manager as a Manager tab in the left sidebar instead of a top-bar button (only for accounts with *Manager*) |
| `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 shared workflows nobody opened for this long |
| `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 |
| `chat_history` | `200` | Chat messages 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. Admins can also clear it under *Admin → Chat* |
## Environment variables
+23 -20
View File
@@ -1,22 +1,25 @@
# Sharing workflows
Every shared workflow is a *room* on the server. The server keeps the current version of each room and relays edits between everyone who has it open.
Sharing follows folders, like a shared drive. There is nothing to share by hand: where a workflow is saved decides who works on it with you.
## Which tabs are shared
## Which tabs are live
| Tab | Shared? |
| Tab | Live? |
|---|---|
| A saved workflow file | Yes, automatically. Everyone who opens the same file lands in the same room. |
| An unsaved tab | As soon as you edit it, or when you click **Share this tab**. Untouched tabs, such as the default workflow or tabs restored from your last visit, stay private. |
| A workflow saved in **Shared** or at the top level of the workflows folder | Yes, while it is open. Everyone who opens the same file lands in the same room and edits it together. |
| A workflow in **My files** (your private folder) | No. Only you (and admins) can open it. |
| An unsaved tab | No. Save it outside your private folder to work on it with others. |
| A snapshot opened as a copy | No, it is a private copy. |
Both automatic behaviours can be turned off in ComfyUI's settings under *RigShare*.
To take a private workflow live, move it out of *My files*: **Move to Shared…** in the panel, **Move to…** in the Files tab, or save it somewhere shared.
Only the tab on screen is live. Switch to another tab and you leave the room; switch back and you rejoin. If the room changed meanwhile, you get the current version.
Only the tab on screen is live. Switch to another tab and you leave the room; switch back and you rejoin. If the workflow changed meanwhile, you get the current version.
## The panel
**Workflows** shows the tab on screen: whether it is live, who else is in it, and buttons for **Snapshot**, **History** and **Stop sharing**. Below that is the list of shared workflows, with the people in each one. Open any of them with the arrow.
**Live** shows the tab on screen: whether it is live, who else is in it, and buttons for **Snapshot** and **History**. For a private or unsaved tab it says why it is not live. Below that is the list of **live workflows**: those someone has open right now, with their folder and the people in each one. Open any of them with the arrow. The Files tab marks live files with a green dot.
**Queue** shows the run in progress and the ones waiting, each with the workflow's name, who queued it and when. The button on a row stops or removes it: your own runs, or anyone's for an admin.
**People** lists who is online and which workflow they are on. The eye button *follows* someone: it jumps to their tab and keeps your view on theirs until you click or scroll the canvas.
@@ -28,34 +31,34 @@ 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 in the same docked side panel as ComfyUI's own tabs.
Viewers (no *Edit* permission) see a read-only canvas. Anything they change locally is put back.
## Stop sharing
## When a workflow stops being live
**Stop sharing** takes the tab out of its room and keeps working on a private copy.
- For an **admin**, if nobody else is in the room, it is also removed from the shared list, and a *Before unshare* snapshot is kept in its history.
- Otherwise, including for everyone who is not an admin, you just leave. It stays shared for the others.
Only admins can remove a shared workflow. Workflows nobody has open show a 🗑 button for them in the list. For a saved file, stopping sharing only applies to you. Anyone else who opens the file shares it again, unless they turned off automatic sharing.
Rooms nobody opens for 14 days are forgotten (`room_expiry_days`).
- When everyone closes it, it leaves the live list. The server keeps its latest state, so the next person to open it picks up where the others left off, saved or not. That state is forgotten after 14 days without anyone opening it (`room_expiry_days`).
- Renaming or moving it (or its folder) keeps it live: everyone who has it open follows it to the new name, and its history goes with it.
- Moving it into a private folder takes it out of live sharing straight away. Its owner's tab follows it; everyone else keeps a private copy.
- Deleting it (admins, or the owner of its shared folder) closes it for the people who have it open. Their tabs stay as private copies, and a *Before delete* snapshot is kept.
## Who can open it
By default everyone with an account can open a shared workflow, with their usual permissions. The **Access** button under *This tab* lets its owner, whoever shared it first, or any admin 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**.
- The owner and admins always have access.
- 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.
- A role can only narrow someone's account permissions: *Can edit* does nothing for an account without *Edit*.
- People who lose access are taken out of the workflow straight away; their tab stays open as a private copy.
- Without access, the workflow is gone from their list and from the People tab, and they cannot open it, its snapshots, or its file through ComfyUI.
- A 🔒 marks restricted workflows in the list.
- A 🔒 marks workflows in restricted folders in the live list.
Saved workflow files still show up by name in ComfyUI's workflow browser; opening one without access is refused.
## 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.
+6 -3
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.0.0"
description = "Turn one ComfyUI server into a shared team workspace. Sign-in with accounts, permissions (edit, queue, Manager, admin) and personal API keys, enforced by the server. A private folder for each person plus shared folders with access lists, with Save asking where. Live co-editing of every shared workflow, with cursors, follow mode and conflict-free merges, in the graph or App view. Per-workflow history with a snapshot on every save, team chat with its whole history, and a CPU, RAM, GPU and queue monitor."
version = "1.3.2"
license = { file = "LICENSE" }
dependencies = []
@@ -13,6 +13,9 @@ Documentation = "https://github.com/xiaodoudou/ComfyUI-RigShare#documentation"
"Bug Tracker" = "https://github.com/xiaodoudou/ComfyUI-RigShare/issues"
[tool.comfy]
PublisherId = ""
PublisherId = "xiaodoudou"
DisplayName = "ComfyUI RigShare"
Icon = "https://raw.githubusercontent.com/xiaodoudou/ComfyUI-RigShare/master/web/rigshare/assets/icon.png"
[tool.pytest.ini_options]
testpaths = ["tests/python"]
+107
View File
@@ -0,0 +1,107 @@
"""Chat history: an append-only log, read back one page at a time.
Messages are stored one JSON object per line in ``chat.jsonl``. Each gets a
sequence number (``seq``, its position in the log), so clients can ask for
"the 50 messages before #1234" while scrolling back. Only the byte offset of
each line is kept in memory, so the history can grow without limit.
"""
import json
import logging
import os
import threading
log = logging.getLogger("ComfyUI-RigShare")
class ChatLog:
def __init__(self, folder, keep=0, legacy=None):
"""``keep`` > 0 trims the log to that many messages at startup (0 keeps everything).
``legacy``: messages from the old ``chat.json``, imported once."""
self.path = os.path.join(folder, "chat.jsonl")
self._lock = threading.Lock()
if legacy and not os.path.exists(self.path):
with open(self.path, "w", encoding="utf-8") as f:
for msg in legacy:
if isinstance(msg, dict):
f.write(json.dumps({k: v for k, v in msg.items() if k != "seq"}, ensure_ascii=False) + "\n")
self.offsets = []
self._index()
if keep and len(self.offsets) > keep:
self._trim(keep)
def _index(self):
self.offsets = []
if not os.path.exists(self.path):
return
torn = None
with open(self.path, "rb") as f:
pos = 0
for line in f:
if not line.endswith(b"\n"):
torn = pos # cut short by a crash mid-write
elif line.strip():
self.offsets.append(pos)
pos += len(line)
if torn is not None:
with open(self.path, "r+b") as f:
f.truncate(torn)
def _trim(self, keep):
messages = self.read(len(self.offsets) - keep, len(self.offsets))
tmp = self.path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
for msg in messages:
msg.pop("seq", None)
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
os.replace(tmp, self.path)
self._index()
def clear(self):
"""Delete the whole history; numbering starts again at 0."""
with self._lock:
with open(self.path, "w", encoding="utf-8"):
pass
self.offsets = []
def __len__(self):
return len(self.offsets)
def append(self, msg):
"""Store a message; returns it with its ``seq``."""
with self._lock:
msg = {**msg, "seq": len(self.offsets)}
data = (json.dumps({k: v for k, v in msg.items() if k != "seq"}, ensure_ascii=False) + "\n").encode("utf-8")
try:
with open(self.path, "ab") as f:
pos = f.tell()
f.write(data)
self.offsets.append(pos)
except OSError as e:
log.error(f"[RigShare] could not save chat message: {e}")
return msg
def read(self, start, end):
"""Messages ``start`` <= seq < ``end``."""
start, end = max(0, start), min(end, len(self.offsets))
if start >= end:
return []
out = []
with open(self.path, "rb") as f:
f.seek(self.offsets[start])
for seq in range(start, end):
line = f.readline()
try:
msg = json.loads(line)
except ValueError:
continue
msg["seq"] = seq
out.append(msg)
return out
def page(self, before=None, limit=50):
"""The ``limit`` messages before ``before`` (newest when None), oldest first,
and whether older ones exist."""
end = len(self.offsets) if before is None else max(0, min(int(before), len(self.offsets)))
start = max(0, end - max(1, min(int(limit), 200)))
return self.read(start, end), start > 0
+258 -119
View File
@@ -1,9 +1,9 @@
"""Real-time hub: presence, chat, cursors, server stats and shared rooms.
Every workflow tab that is shared maps to a *room* (a saved workflow file is
the room ``file:<path>``; a shared unsaved tab is ``tmp:<id>``). The server
holds the authoritative document of each room and relays patches between the
browsers that currently have that room on screen.
Sharing follows folders: every saved workflow outside a private folder is a
*room* (``file:<path>``) while it is open. The server holds the authoritative
document of each room and relays patches between the browsers that currently
have it on screen. Unsaved tabs and private files never get a room.
Clients identify once (session token or guest id); from then on the server
stamps relayed messages with the sender identity and checks permissions.
@@ -13,20 +13,24 @@ import asyncio
import hashlib
import json
import logging
import posixpath
import secrets
import time
from collections import deque
from collections import OrderedDict
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, has_app
log = logging.getLogger("ComfyUI-RigShare")
COLORS = ["#f87171", "#fb923c", "#facc15", "#4ade80", "#2dd4bf", "#38bdf8",
"#818cf8", "#c084fc", "#f472b6", "#a3e635", "#fbbf24", "#22d3ee"]
MAX_MESSAGE = 16 * 1024 * 1024
CHAT_PAGE = 50
def clean_text(text, limit):
@@ -34,18 +38,16 @@ def clean_text(text, limit):
return text.strip()[:limit]
ROLES = ("view", "edit")
def is_live_key(key):
"""Only saved workflows outside a private folder can be live."""
if not isinstance(key, str) or not key.startswith("file:workflows/"):
return False
return Workspace.area(key[len("file:workflows/"):])[0] not in ("private", "users-root")
class Room:
def __init__(self, key, name, kind, doc, version=0, updated=None, owner=None, acl=None):
def __init__(self, key, name, kind, doc, version=0, updated=None):
self.key = key
# Username of whoever first shared it; may manage its access list.
self.owner = owner
# None: everyone with an account can open it. Otherwise
# {"members": {username: "view" | "edit"}}: only those people (plus
# the owner and admins). A room role can only narrow account permissions.
self.acl = acl
self.name = name
self.kind = kind
self.doc = doc
@@ -58,7 +60,7 @@ class Room:
def to_file(self):
return {"key": self.key, "name": self.name, "kind": self.kind, "doc": self.doc,
"version": self.version, "updated": self.updated, "owner": self.owner, "acl": self.acl}
"version": self.version, "updated": self.updated}
class Client:
@@ -76,12 +78,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:
@@ -91,18 +94,22 @@ class Hub:
self.clients = {}
self.rooms = {}
for data in store.load_rooms():
if not is_live_key(data.get("key")):
# Unsaved-tab rooms and private files from older versions.
store.delete_room(data.get("key", ""))
continue
try:
self.rooms[data["key"]] = Room(data["key"], data.get("name") or data["key"], data.get("kind", "file"),
data.get("doc"), data.get("version", 0), data.get("updated"),
data.get("owner"), data.get("acl"))
data.get("doc"), data.get("version", 0), data.get("updated"))
except (KeyError, TypeError):
continue
self.chat = deque(maxlen=int(store.config.get("chat_history", 200)))
for msg in store._read("chat.json", []):
self.chat.append(msg)
self.chat_dirty = False
self.chat = ChatLog(store.folder, keep=int(store.config.get("chat_keep") or 0),
legacy=store._read("chat.json", None))
self.stats = None
self._task = None
self.workspace = None # set by __init__ (folders, private spaces)
self.queue_owners = OrderedDict() # prompt_id -> {user, name, workflow, at}
self.run_started = {} # prompt_id -> ms timestamp of its execution_start (set by routes)
# ----- lifecycle ----------------------------------------------------
@@ -128,9 +135,6 @@ class Hub:
if expiry > 0 and now - room.updated > expiry and not self.members(room.key):
self.rooms.pop(room.key, None)
self.store.delete_room(room.key)
if self.chat_dirty:
self.store._write("chat.json", list(self.chat))
self.chat_dirty = False
except Exception as e:
log.error(f"[RigShare] housekeeping error: {e}")
@@ -142,10 +146,88 @@ class Hub:
continue
try:
self.stats = await loop.run_in_executor(None, collect_stats, self.server)
self.stats["queue_items"] = self.with_owners(self.stats.get("queue_items"))
self.stats["performance"] = self.performance(self.stats["queue_items"])
await self.broadcast({"type": "stats", "stats": self.stats})
except Exception as e:
log.debug(f"[RigShare] stats error: {e}")
# ----- queue ----------------------------------------------------------
def record_prompt(self, prompt_id, user, workflow):
"""Remember who queued a prompt (ComfyUI itself doesn't)."""
if not prompt_id:
return
self.queue_owners[prompt_id] = {
"user": user["username"] if user else None,
"name": (user.get("display_name") or user["username"]) if user else None,
"workflow": clean_text(workflow, 120) or None, "at": int(time.time())}
while len(self.queue_owners) > 2000:
self.queue_owners.popitem(last=False)
def owner_of(self, prompt_id):
return (self.queue_owners.get(prompt_id) or {}).get("user")
def with_owners(self, items):
if items is None:
return None
return [{**it, **(self.queue_owners.get(it["id"]) or {})} for it in items]
RESULTS = {"execution_success": "done", "execution_error": "error", "execution_interrupted": "stopped"}
def run_event(self, event, data):
"""ComfyUI execution events (from routes' send_sync hook): time each run.
Both events carry ComfyUI's own millisecond timestamp, so durations are exact.
"""
prompt_id = data.get("prompt_id") if isinstance(data, dict) else None
if not prompt_id:
return
stamp = data.get("timestamp") or int(time.time() * 1000)
if event == "execution_start":
self.run_started[prompt_id] = stamp
elif event in self.RESULTS and prompt_id in self.run_started:
start = self.run_started.pop(prompt_id)
owner = self.queue_owners.get(prompt_id) or {}
self.store.add_run({"id": prompt_id, "ms": max(0, stamp - start), "end": stamp,
"result": self.RESULTS[event], "workflow": owner.get("workflow"),
"user": owner.get("user"), "name": owner.get("name")})
def etas(self):
"""Expected duration per workflow: the average of its last 5 successful runs."""
done = {}
for run in reversed(self.store.runs()):
wf = run.get("workflow")
if wf and run.get("result") == "done" and len(done.setdefault(wf, [])) < 5:
done[wf].append(run["ms"])
return {wf: {"ms": int(sum(v) / len(v)), "runs": len(v)} for wf, v in done.items() if v}
def performance(self, items, recent=5):
"""The run in progress against its workflow's ETA, the last runs, and every ETA."""
now = int(time.time() * 1000)
etas = self.etas()
running = []
for item in items or []:
if item.get("status") != "running":
continue
started = self.run_started.setdefault(item["id"], now)
eta = etas.get(item.get("workflow")) or {}
running.append({"id": item["id"], "workflow": item.get("workflow"), "user": item.get("user"),
"name": item.get("name"), "elapsed_ms": max(0, now - started),
"eta_ms": eta.get("ms"), "runs": eta.get("runs", 0)})
live = {r["id"] for r in running}
for prompt_id in [k for k in self.run_started if k not in live and now - self.run_started[k] > 86400000]:
del self.run_started[prompt_id] # never finished (server restarted mid-run...)
return {"running": running, "recent": list(reversed(self.store.runs()[-recent:])),
"etas": etas, "now": now}
def running_prompt_ids(self):
try:
running, _ = self.server.prompt_queue.get_current_queue_volatile()
return [entry[1] for entry in running]
except Exception:
return []
def snapshot(self, room, author, label=None):
if room.doc is None:
return None
@@ -224,66 +306,72 @@ class Hub:
except Exception:
pass
def drop_if_unused(self, key):
"""Forget an unsaved-tab room nobody edited once its last member leaves."""
room = self.rooms.get(key)
if room and room.kind == "tmp" and room.version == 0 and not self.members(key):
self.rooms.pop(key, None)
async def drop_rooms(self, prefix, by, label="Before delete"):
"""The file (or folder) is gone or went private: forget its rooms. Snapshots are kept."""
dropped = False
for key in list(self.rooms):
if key != prefix and not key.startswith(prefix + "/"):
continue
room = self.rooms.pop(key)
self.store.delete_room(key)
def remove_room(self, key, by):
"""Stop sharing a workflow nobody has open. Snapshots are kept."""
room = self.rooms.get(key)
if room is None or self.members(key):
return False
if room.doc is not None and room.version > 0:
self.snapshot(room, by, "Before unshare")
self.rooms.pop(key, None)
self.store.delete_room(key)
return True
if room.doc is not None and room.version > 0:
self.snapshot(room, by, label)
for client in self.members(key):
client.room = None
await self.send(client, {"type": "room_closed", "room": key, "name": room.name})
dropped = True
if dropped:
await self.broadcast_presence()
def members(self, key):
return [c for c in self.clients.values() if c.kind and c.room == key]
# ----- access lists -------------------------------------------------
# ----- access ---------------------------------------------------------
@staticmethod
def access(room, kind, key, perms):
"""What someone may do in a room: None (no access), "view" or "edit"."""
can_edit = bool(perms.get("edit") or perms.get("admin"))
if perms.get("admin") or not room.acl:
return "edit" if can_edit else "view"
if kind == "user" and key == room.owner:
return "edit" if can_edit else "view"
role = (room.acl.get("members") or {}).get(key) if kind == "user" else None
if role not in ROLES:
return None
return "edit" if role == "edit" and can_edit else "view"
def access(self, room, kind, key, perms):
"""What someone may do in a room: None (no access), "view" or "edit".
@staticmethod
def can_manage(room, kind, key, perms):
return bool(perms.get("admin")) or (kind == "user" and key is not None and key == room.owner)
Access follows the workflow's folder (restricted shared folders), within
the person's account permissions.
"""
return self.access_for_key(room.key, kind, key, perms)
def access_for_key(self, room_key, kind, key, perms):
"""Same as access(), from a room key: works before (or without) a live room."""
if self.workspace and room_key.startswith("file:workflows/"):
return self.workspace.role(room_key[len("file:workflows/"):], kind, key, perms)
return "edit" if (perms.get("edit") or perms.get("admin")) else "view"
def client_access(self, room, client):
return self.access(room, client.kind, client.key, client.perms)
def folder_of(self, room):
"""The shared folder ("shared/<name>") a room's file lives in, or None."""
area, folder = Workspace.area(room.key[len("file:workflows/"):])
return folder if area == "shared" else None
def folder_restricted(self, folder):
return bool(folder and self.workspace and (self.workspace.folders.get(folder) or {}).get("acl"))
def room_info(self, room, client):
return {"role": self.client_access(room, client),
"manage": self.can_manage(room, client.kind, client.key, client.perms),
"restricted": bool(room.acl), "owner": room.owner}
folder = self.folder_of(room)
manage = bool(folder and self.workspace and self.workspace.can_manage(folder, client.kind, client.key, client.perms))
return {"role": self.client_access(room, client), "folder": folder,
"restricted": self.folder_restricted(folder), "manage": manage}
def room_list(self, client=None):
cutoff = time.time() - 7 * 86400
"""Live workflows: the rooms someone has open right now."""
out = []
for room in self.rooms.values():
if client is not None and self.client_access(room, client) is None:
continue
members = [c.id for c in self.members(room.key)]
if members or room.updated > cutoff:
if members:
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": bool(room.acl), "owner": room.owner})
"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
@@ -306,11 +394,69 @@ class Hub:
await self.send(client, {"type": "presence", "users": self.users_for(client),
"rooms": self.room_list(client)})
async def set_acl(self, room, acl):
"""Apply a new access list and remove people who lost access."""
room.acl = acl
room.dirty = True
for client in self.members(room.key):
def rekey_rooms(self, old_prefix, new_prefix):
"""Follow files that moved: rooms of ``file:<old_prefix>...`` get the new path."""
moved = []
for key in list(self.rooms):
if key == old_prefix or key.startswith(old_prefix + "/"):
room = self.rooms.pop(key)
self.store.delete_room(key)
for client in self.members(key):
client.room = None
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").removesuffix(".app")
room.dirty = True
self.rooms[room.key] = room
moved.append(room.key)
return moved
def path_access(self, path, client, key=None):
"""May ``client`` open the workflows path (file or folder)?"""
key = client.key if key is None else key
rel = Workspace.workflows_rel(path)
if rel is None or not self.workspace:
return True
return self.workspace.role(rel, client.kind, key, client.perms) is not None
async def files_changed(self, *paths):
"""Workflow files changed in these folders (userdata paths): tell whoever can
open one of them, so their Files tab reloads."""
folders = {posixpath.dirname(p) if p.endswith(".json") else p for p in paths if p}
for client in list(self.clients.values()):
if client.kind and any(self.path_access(f, client) for f in folders):
await self.send(client, {"type": "files_changed"})
def who_sees(self, path):
"""Connected clients that may open ``path``; call before moving it."""
return [c for c in self.clients.values() if c.kind and self.path_access(path, c)]
async def path_moved(self, old, new, renamed=None, seen_by=None):
"""A workflow file or folder moved (userdata paths, e.g. ``workflows/a.json``).
Its rooms follow it, and everyone who could see the old location
(``seen_by``, taken before the move changed any folder settings) is told
where it went so their open tabs follow too, or that it went somewhere
they cannot open. ``renamed`` maps usernames changed by the same action.
"""
notes = []
for client in self.who_sees(old) if seen_by is None else seen_by:
key = (renamed or {}).get(client.key, client.key) if client.kind == "user" else client.key
notes.append((client, self.path_access(new, client, key=key)))
self.rekey_rooms("file:" + old, "file:" + new)
await self.files_changed(posixpath.dirname(old), posixpath.dirname(new), new)
for client, follows in notes:
await self.send(client, {"type": "path_moved", "from": old, "to": new} if follows
else {"type": "path_closed", "from": old})
await self.broadcast_presence()
async def refresh_rooms_access(self):
"""Folder access changed: remove people from rooms they can no longer open."""
for client in list(self.clients.values()):
room = self.rooms.get(client.room) if client.room else None
if not room:
continue
role = self.client_access(room, client)
if role is None:
client.room = None
@@ -320,36 +466,26 @@ class Hub:
await self.send(client, {"type": "room_info", "room": room.key, **self.room_info(room, client)})
await self.broadcast_presence()
def rename_member(self, old, new):
for room in self.rooms.values():
changed = False
if room.owner == old:
room.owner, changed = new, True
members = (room.acl or {}).get("members") or {}
if old in members:
members[new] = members.pop(old)
changed = True
room.dirty = room.dirty or changed
def forget_member(self, username):
for room in self.rooms.values():
members = (room.acl or {}).get("members") or {}
if members.pop(username, None) is not None:
room.dirty = True
def server_info(self):
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)),
"manager_tab": bool(cfg.get("manager_tab")),
"hide_api_templates": bool(cfg.get("hide_api_templates"))}
async def broadcast_server(self):
await self.broadcast({"type": "server", "server": self.server_info()})
async def clear_chat(self, by):
"""An admin wiped the chat: everyone's history empties, then a notice says who."""
self.chat.clear()
await self.broadcast({"type": "chat_cleared"})
await self.system_chat(f"{by} cleared the chat history")
async def system_chat(self, text):
msg = {"id": secrets.token_hex(6), "system": True, "text": text, "ts": int(time.time() * 1000)}
self.chat.append(msg)
self.chat_dirty = True
msg = self.chat.append(msg)
await self.broadcast({"type": "chat", "message": msg})
# ----- websocket ----------------------------------------------------
@@ -358,7 +494,8 @@ class Hub:
ws = web.WebSocketResponse(heartbeat=25, max_msg_size=MAX_MESSAGE)
await ws.prepare(request)
client = Client(ws)
client.cookie_token = request.cookies.get("rigshare_session")
auth = request.headers.get("Authorization", "")
client.cookie_token = request.cookies.get("rigshare_session") or (auth[7:].strip() if auth.startswith("Bearer ") else None)
self.clients[client.id] = client
try:
async for msg in ws:
@@ -378,8 +515,6 @@ class Hub:
log.error(f"[RigShare] websocket error: {e}")
finally:
self.clients.pop(client.id, None)
if client.room:
self.drop_if_unused(client.room)
if client.kind:
await self.broadcast({"type": "leave", "id": client.id})
await self.broadcast_presence()
@@ -395,12 +530,17 @@ class Hub:
await self.send(client, {"type": "denied", "reason": "Please log in.", "login": True})
return
first = not any(c.key == client.key and c is not client for c in self.clients.values())
if self.workspace and client.kind == "user":
try:
self.workspace.ensure_private(client.key)
except OSError:
pass
await self.send(client, {
"type": "welcome",
"self": client.public(),
"users": self.users_for(client),
"rooms": self.room_list(client),
"chat": list(self.chat),
**dict(zip(("chat", "chat_more"), self.chat.page(limit=CHAT_PAGE))),
"stats": self.stats,
"server": self.server_info(),
})
@@ -413,21 +553,32 @@ class Hub:
previous = client.room
if not key:
client.room = None
elif not is_live_key(key):
client.room = None
await self.send(client, {"type": "room_denied", "room": key, "name": clean_text(data.get("name"), 120) or key,
"reason": "Only saved workflows outside a private folder are live."})
else:
room = self.rooms.get(key)
created = False
if room is None:
doc = data.get("doc") if isinstance(data.get("doc"), dict) else {}
room_kind = "file" if key.startswith("file:") else "tmp"
room = Room(key, clean_text(data.get("name"), 120) or key, room_kind, doc,
owner=client.key if client.kind == "user" else None)
room.dirty = True
self.rooms[key] = room
created = True
if self.client_access(room, client) is None:
# Check access before anything is created: only someone who may edit the
# workflow can open a room and seed it with their document.
role = self.access_for_key(key, client.kind, client.key, client.perms)
if role is None:
client.room = None
await self.send(client, {"type": "room_denied", "room": key, "name": room.name})
await self.send(client, {"type": "room_denied", "room": key,
"name": room.name if room else clean_text(data.get("name"), 120) or key})
elif room is None and role != "edit":
client.room = None
await self.send(client, {"type": "room_denied", "room": key,
"name": clean_text(data.get("name"), 120) or key,
"reason": "Nobody has opened this workflow for editing yet."})
else:
if room is None:
doc = data.get("doc") if isinstance(data.get("doc"), dict) else {}
room = Room(key, clean_text(data.get("name"), 120) or key, "file", doc)
room.dirty = True
self.rooms[key] = room
created = True
client.room = key
room.updated = time.time()
await self.send(client, {"type": "room", "room": key, "name": room.name, "version": room.version,
@@ -436,25 +587,8 @@ class Hub:
if previous != client.room:
if previous:
await self.broadcast({"type": "leave", "id": client.id}, room=previous)
self.drop_if_unused(previous)
await self.broadcast_presence()
elif kind == "unshare":
# Leave the room; an admin also removes it from the shared list if nobody else is in it.
key = data.get("room")
room = self.rooms.get(key)
if room is None:
return
if client.room == key:
client.room = None
await self.broadcast({"type": "leave", "id": client.id}, room=key)
others = self.members(key)
# Only admins remove a workflow from the shared list; others just leave.
removed = not others and client.perms.get("admin") and self.remove_room(key, client.name)
await self.send(client, {"type": "unshared", "room": key, "name": room.name, "removed": removed,
"others": [c.name for c in others]})
await self.broadcast_presence()
elif kind == "cursor":
if not client.room:
return
@@ -471,8 +605,7 @@ class Hub:
return
msg = {"id": secrets.token_hex(6), "from": client.id, "name": client.name,
"color": client.color, "text": text, "ts": int(time.time() * 1000)}
self.chat.append(msg)
self.chat_dirty = True
msg = self.chat.append(msg)
await self.broadcast({"type": "chat", "message": msg})
elif kind == "rename":
@@ -484,6 +617,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()
+8 -1
View File
@@ -84,6 +84,13 @@
const err = document.getElementById("e");
const btn = document.getElementById("b");
const next = new URLSearchParams(location.search).get("next") || "/";
// Follow `next` only when it resolves to this same origin (blocks //host, /\host, scheme tricks).
const safeNext = () => {
try {
const url = new URL(next, location.origin);
return url.origin === location.origin ? url.pathname + url.search + url.hash : "/";
} catch { return "/"; }
};
if (SETUP) {
document.title = "Set up · {{SERVER_NAME}}";
document.getElementById("sub").textContent = "First-time setup";
@@ -112,7 +119,7 @@
if (res.status === 409) { location.reload(); return; } // someone finished setup meanwhile
if (!res.ok) throw new Error(data.error || "Sign in failed");
try { localStorage.setItem("rigshare.token", data.token); } catch {}
location.replace(next.startsWith("/") && !next.startsWith("//") ? next : "/");
location.replace(safeNext());
} catch (e) {
err.textContent = e.message;
btn.disabled = false;
+428 -69
View File
@@ -7,24 +7,51 @@ import ipaddress
import json
import logging
import os
import posixpath
import time
from urllib.parse import unquote
from collections import defaultdict
from aiohttp import web
from .workspace import InvalidPath, Workspace, norm as _norm
log = logging.getLogger("ComfyUI-RigShare")
# Runs in the ComfyUI page before anything else: any request answered "login
# required" (session ended, cookie cleared) sends the browser to the login page.
LOGIN_WATCH = """<script id="rigshare-login-watch">(() => {
const fetch0 = window.fetch;
let leaving = false;
window.fetch = async (...args) => {
const res = await fetch0(...args);
if (res.status === 401 && !leaving && res.headers.get("X-RigShare-Login") === "required") {
leaving = true;
location.href = "/rigshare/login?next=" + encodeURIComponent(location.pathname + location.search);
}
return res;
};
})();</script>"""
# Other plugins' server routes RigShare guards: (method or None, path prefix,
# permission, why). Settings they keep on the server apply to everyone, like
# ComfyUI Manager, so they need the same "manager" permission; Pixaroma's workflow
# browser reads the workflows folder straight from disk, private folders included.
PLUGIN_RULES = [
("POST", "/rgthree/api/", "manager", "changing rgthree-comfy's settings needs the 'manager' permission"),
("POST", "/crystools/", "manager", "changing Crystools' settings needs the 'manager' permission"),
(None, "/pixaroma/api/workflows", "admin",
"Pixaroma's workflow browser shows every folder, private ones included; use RigShare's Files tab"),
]
# (method, path) prefixes that need a permission when api_protection is on.
PROTECTED = [
("POST", "/prompt", "queue"),
("POST", "/queue", "queue"),
("POST", "/interrupt", "queue"),
("POST", "/free", "queue"),
("POST", "/free", "admin"), # unloading models affects everyone's runs
("POST", "/upload/", "edit|queue"),
("POST", "/api/jobs", "queue"),
# Saved workflow files: only admins delete, editors save/overwrite/rename.
("DELETE", "/userdata/workflows/", "admin"),
("POST", "/userdata/workflows/", "edit"),
]
# ComfyUI Manager: everything needs the "manager" permission except a few
@@ -121,6 +148,13 @@ def setup(server, store, hub):
def error(message, status=400):
return web.json_response({"error": message}, status=status)
def norm(path):
"""workspace.norm, answering 403 for a path that is refused instead of adjusted."""
try:
return _norm(path)
except InvalidPath:
raise web.HTTPForbidden(text='{"error": "Invalid path"}', content_type="application/json")
def set_cookie(request, response, token):
secure = request.secure or request.headers.get("X-Forwarded-Proto") == "https"
response.set_cookie(COOKIE, token, max_age=30 * 24 * 3600, path="/", httponly=True,
@@ -292,7 +326,10 @@ def setup(server, store, hub):
except ValueError as e:
return error(str(e))
if user["username"] != username:
hub.rename_member(username, user["username"])
if hub.workspace:
hub.workspace.rename_user(username, user["username"])
await hub.path_moved(f"workflows/users/{username}", f"workflows/users/{user['username']}",
renamed={username: user["username"]})
log.info(f"[RigShare] {admin['username']} renamed @{username} to @{user['username']}")
await hub.refresh_identities()
else:
@@ -305,7 +342,8 @@ def setup(server, store, hub):
username = request.match_info["username"]
try:
store.delete_user(username)
hub.forget_member(username)
if hub.workspace:
hub.workspace.forget_member(username)
except KeyError:
return error("No such user", 404)
except ValueError as e:
@@ -341,10 +379,191 @@ def setup(server, store, hub):
return user.get("display_name") or user["username"]
return "guest"
@routes.get("/rigshare/api/chat")
async def chat_history(request):
"""Older chat messages: ``before`` is the seq of the oldest one already shown."""
try:
before = request.query.get("before")
before = int(before) if before not in (None, "") else None
limit = int(request.query.get("limit", 50))
except ValueError:
return error("before and limit must be numbers")
messages, more = hub.chat.page(before, limit)
return web.json_response({"messages": messages, "more": more})
@routes.delete("/rigshare/api/chat")
async def clear_chat(request):
admin = require_admin(request)
await hub.clear_chat(admin.get("display_name") or admin["username"])
log.info(f"[RigShare] {admin['username']} cleared the chat history")
return web.json_response({"ok": True})
@routes.get("/rigshare/api/performance/history")
async def performance_history(request):
"""Finished runs, newest first (?limit=, at most 500; ?workflow= to filter)."""
try:
limit = max(1, min(500, int(request.query.get("limit", 100))))
except ValueError:
return error("limit must be a number")
workflow = request.query.get("workflow")
runs = [r for r in reversed(store.runs()) if not workflow or r.get("workflow") == workflow]
return web.json_response({"runs": runs[:limit], "total": len(runs)})
@routes.delete("/rigshare/api/performance")
async def performance_clear(request):
"""Forget one workflow's timings, so its ETA starts over."""
if not perms_for(request).get("edit"):
return error("You need the Edit permission to clear timings", 403)
workflow = request.query.get("workflow", "").strip()
if not workflow:
return error("Which workflow?")
gone = store.clear_runs(workflow)
log.info(f"[RigShare] {display(request)} cleared the timings of {workflow} ({gone} runs)")
return web.json_response({"ok": True, "cleared": gone})
@routes.get("/rigshare/api/rooms")
async def list_rooms(request):
return web.json_response([r for r in hub.room_list() if room_access(request, hub.rooms[r["key"]])])
# ----- workspace folders ------------------------------------------
def ws():
if not hub.workspace:
raise web.HTTPServiceUnavailable(text='{"error": "Workspace unavailable"}', content_type="application/json")
return hub.workspace
@routes.get("/rigshare/api/workspace")
async def workspace_tree(request):
if not current_user(request):
return error("Not logged in", 401)
return web.json_response(ws().tree(*who(request)))
def ws_call(fn):
try:
return None, fn()
except PermissionError as e:
return error(str(e), 403), None
except FileNotFoundError as e:
return error(str(e), 404), None
except ValueError as e:
return error(str(e)), None
@routes.get("/rigshare/api/workspace/list")
async def ws_list(request):
if not current_user(request):
return error("Not logged in", 401)
err, result = ws_call(lambda: ws().list_dir(request.query.get("path", ""), *who(request)))
return err or web.json_response(result)
@routes.post("/rigshare/api/workspace/mkdir")
async def ws_mkdir(request):
body = await request.json()
err, path = ws_call(lambda: ws().make_dir(body.get("parent", ""), body.get("name"), *who(request)))
if err:
return err
await hub.files_changed("workflows/" + posixpath.dirname(path))
return web.json_response({"path": path})
@routes.post("/rigshare/api/workspace/rename-folder")
async def ws_rename_dir(request):
body = await request.json()
old = norm(body.get("path"))
seen_by = hub.who_sees("workflows/" + old)
err, new = ws_call(lambda: ws().rename_dir(old, body.get("name"), *who(request)))
if err:
return err
await hub.path_moved("workflows/" + old, "workflows/" + new, seen_by=seen_by)
return web.json_response({"path": new})
@routes.post("/rigshare/api/workspace/move-folder")
async def ws_move_dir(request):
body = await request.json()
old = norm(body.get("path"))
seen_by = hub.who_sees("workflows/" + old)
err, new = ws_call(lambda: ws().move_dir(old, body.get("dest", ""), *who(request)))
if err:
return err
await hub.path_moved("workflows/" + old, "workflows/" + new, seen_by=seen_by)
return web.json_response({"path": new})
@routes.post("/rigshare/api/workspace/delete-folder")
async def ws_delete_dir(request):
body = await request.json()
path = norm(body.get("path", ""))
err, _ = ws_call(lambda: ws().delete_dir(path, *who(request)))
if err:
return err
await hub.drop_rooms("file:workflows/" + path, display(request))
await hub.files_changed("workflows/" + posixpath.dirname(path))
return web.json_response({"ok": True})
@routes.get("/rigshare/api/workspace/folder")
async def folder_acl(request):
folder = norm(request.query.get("path"))
if Workspace.area(folder)[0] != "shared" or folder.count("/") != 1:
return error("Not a shared folder")
if not ws().can_manage(folder, *who(request)):
return error("Only the folder's owner or an admin can see its access list", 403)
meta = ws().folders.get(folder) or {}
return web.json_response({"path": folder, "owner": meta.get("owner"), "restricted": bool(meta.get("acl")),
"members": (meta.get("acl") or {}).get("members") or {}})
@routes.post("/rigshare/api/workspace/folders")
async def create_folder(request):
kind, key, perms = who(request)
if kind != "user" or not (perms.get("edit") or perms.get("admin")):
return error("You need the Edit permission to create shared folders", 403)
body = await request.json()
try:
folder = ws().create_folder(body.get("name"), key)
except ValueError as e:
return error(str(e))
log.info(f"[RigShare] {key} created folder {folder}")
await hub.files_changed("workflows/shared")
return web.json_response({"path": folder})
@routes.patch("/rigshare/api/workspace/folders")
async def update_folder(request):
body = await request.json()
folder = norm(body.get("path"))
if Workspace.area(folder)[0] != "shared" or folder.count("/") != 1:
return error("Not a shared folder")
if not ws().can_manage(folder, *who(request)):
return error("Only the folder's owner or an admin can change it", 403)
try:
if "restricted" in body:
acl = None
if body.get("restricted"):
owner = (ws().folders.get(folder) or {}).get("owner")
members = {u: r for u, r in (body.get("members") or {}).items()
if u in store.users and r in ("view", "edit") and u != owner}
acl = {"members": members}
kind, key, _ = who(request)
ws().set_acl(folder, acl, fallback_owner=key if kind == "user" else None)
if body.get("name"):
seen_by = hub.who_sees(f"workflows/{folder}")
new_folder = ws().rename_folder(folder, body["name"])
await hub.path_moved(f"workflows/{folder}", f"workflows/{new_folder}", seen_by=seen_by)
folder = new_folder
except ValueError as e:
return error(str(e))
await hub.refresh_rooms_access()
await hub.files_changed("workflows/shared") # the folder's access changed who sees it
return web.json_response({"ok": True, "path": folder})
@routes.delete("/rigshare/api/workspace/folders")
async def delete_folder(request):
folder = norm(request.query.get("path"))
if Workspace.area(folder)[0] != "shared" or folder.count("/") != 1:
return error("Not a shared folder")
if not ws().can_manage(folder, *who(request)):
return error("Only the folder's owner or an admin can delete it", 403)
try:
ws().delete_folder(folder)
except ValueError as e:
return error(str(e))
return web.json_response({"ok": True})
@routes.get("/rigshare/api/people")
async def people(request):
"""Accounts that can be added to a workflow's access list."""
@@ -354,55 +573,6 @@ def setup(server, store, hub):
"admin": bool(u["perms"].get("admin")), "edit": bool(u["perms"].get("edit"))}
for u in store.users.values()])
@routes.get("/rigshare/api/room/acl")
async def get_acl(request):
room = hub.rooms.get(request.query.get("key", ""))
if not room:
return error("No such room", 404)
if not hub.can_manage(room, *who(request)):
return error("Only the owner or an admin can manage access", 403)
return web.json_response({"key": room.key, "owner": room.owner, "restricted": bool(room.acl),
"members": (room.acl or {}).get("members") or {}})
@routes.put("/rigshare/api/room/acl")
async def put_acl(request):
body = await request.json()
room = hub.rooms.get(body.get("key", ""))
if not room:
return error("No such room", 404)
if not hub.can_manage(room, *who(request)):
return error("Only the owner or an admin can manage access", 403)
acl = None
if body.get("restricted"):
members = {}
for username, role in (body.get("members") or {}).items():
if username in store.users and role in ("view", "edit") and username != room.owner:
members[username] = role
acl = {"members": members}
if room.owner is None:
# Someone must stay in charge besides admins: the person restricting it.
kind, key, _ = who(request)
if kind == "user":
room.owner = key
await hub.set_acl(room, acl)
log.info(f"[RigShare] {display(request)} set access for {room.name}: "
f"{'everyone' if acl is None else ', '.join(f'{u}={r}' for u, r in acl['members'].items()) or 'owner only'}")
return web.json_response({"ok": True, "owner": room.owner, "restricted": bool(acl)})
@routes.delete("/rigshare/api/room")
async def delete_room(request):
require_admin(request)
key = request.query.get("key", "")
room = hub.rooms.get(key)
if not room:
return error("No such room", 404)
if hub.members(key):
names = ", ".join(c.name for c in hub.members(key))
return error(f"Still open by {names}", 409)
hub.remove_room(key, display(request))
await hub.broadcast_presence()
return web.json_response({"ok": True})
@routes.get("/rigshare/api/room")
async def get_room(request):
room = hub.rooms.get(request.query.get("key", ""))
@@ -417,8 +587,7 @@ def setup(server, store, hub):
if not key:
require_admin(request)
return web.json_response(store.list_snapshots())
room = hub.rooms.get(key)
if room and not room_access(request, room):
if hub.access_for_key(key, *who(request)) is None:
return error("No such room", 404)
return web.json_response(store.list_snapshots(key))
@@ -442,8 +611,9 @@ def setup(server, store, hub):
snap = store.get_snapshot(request.match_info["snap_id"])
if not snap:
return error("Not found", 404)
room = hub.rooms.get(snap["meta"].get("room", ""))
if room and not room_access(request, room):
# Access follows the workflow's folder whether or not anyone has it open right now.
room_key = snap["meta"].get("room", "")
if room_key and hub.access_for_key(room_key, *who(request)) is None:
return error("Not found", 404)
return web.json_response(snap)
@@ -481,6 +651,157 @@ def setup(server, store, hub):
continue
return False
def parse_userdata(request):
"""('list'|'get'|'save'|'delete'|'move', path, dest) for ComfyUI userdata calls under workflows/."""
raw = request.raw_path.split("?", 1)[0]
segs = raw.split("/")
if "userdata" not in segs:
return None
i = segs.index("userdata")
if segs[i - 1] not in ("", "api", "v2"):
return None
if len(segs) == i + 1: # listing: /userdata?dir= or /v2/userdata?path=
if request.method != "GET":
return None
directory = norm(request.query.get("dir") or request.query.get("path") or "")
return ("list", directory, None) if Workspace.workflows_rel(directory) is not None else None
# ComfyUI encodes "/" inside names (one segment each), but accept plain
# slashes too so no spelling of a path can slip past the checks.
rest = segs[i + 1:]
if "move" in rest[1:]:
m = rest.index("move", 1)
src, dest = norm("/".join(rest[:m])), norm("/".join(rest[m + 1:]))
else:
src, dest = norm("/".join(rest)), None
if Workspace.workflows_rel(src) is None and (dest is None or Workspace.workflows_rel(dest) is None):
return None
if dest is not None:
return ("move", src, dest)
action = {"GET": "get", "HEAD": "get", "POST": "save", "DELETE": "delete"}.get(request.method)
return (action, src, None) if action else None
def watch_login(response):
try:
file_path = getattr(response, "_path", None)
if file_path:
with open(file_path, "r", encoding="utf-8") as f:
page = f.read()
elif isinstance(response, web.Response) and response.body and "html" in (response.content_type or ""):
page = response.body.decode("utf-8")
else:
return response
if "<head>" not in page or "rigshare-login-watch" in page:
return response
page = page.replace("<head>", "<head>" + LOGIN_WATCH, 1)
return web.Response(text=page, content_type="text/html",
headers={"Cache-Control": "no-store, must-revalidate"})
except Exception as e:
log.debug(f"[RigShare] could not add the login watch: {e}")
return response
def api_path(request):
return request.path[4:] if request.path.startswith("/api/") else request.path
async def check_queue_ownership(request):
"""Cancelling a queued prompt or stopping the running one: your own, unless admin."""
if request.method != "POST" or api_path(request) not in ("/queue", "/interrupt"):
return None
if trusted(request.remote or "") or perms_for(request).get("admin"):
return None
user = current_user(request)
me = user["username"] if user else None
try:
body = await request.json() if request.body_exists else {}
except ValueError:
body = {}
body = body if isinstance(body, dict) else {}
if api_path(request) == "/queue":
if body.get("clear"):
return "only admins can clear the whole queue"
ids = body.get("delete") or []
else:
ids = [body["prompt_id"]] if body.get("prompt_id") else hub.running_prompt_ids()
for prompt_id in ids:
owner = hub.owner_of(prompt_id)
if owner and owner != me:
return "you can only cancel your own prompts"
return 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)
w = hub.workspace
can_edit = bool(perms.get("edit") or perms.get("admin"))
def role(path):
rel = Workspace.workflows_rel(path)
if rel is None:
return "edit"
return w.role(rel, kind, key, perms) if w else ("edit" if can_edit else "view")
def deletable(path):
rel = Workspace.workflows_rel(path)
if rel is None:
return True
if w:
return w.can_delete(rel, kind, key, perms) and role(path) is not None
return bool(perms.get("admin"))
if action == "list":
return None
if action == "get":
return None if role(src) else "you do not have access to this workflow"
if action == "save":
return None if role(src) == "edit" else "you cannot save in this folder"
if action == "delete":
return None if deletable(src) else "only the folder's owner or an admin can delete this"
if action == "move":
if Workspace.workflows_rel(src) is None or Workspace.workflows_rel(dest) is None:
return "workflows cannot be moved in or out of the workflows folder"
if role(src) is None or role(dest) != "edit":
return "you cannot move this workflow there"
if w:
ok = w.can_move(Workspace.workflows_rel(src), Workspace.workflows_rel(dest), kind, key, perms)
else:
ok = role(src) == "edit" and (perms.get("admin") or src.rsplit("/", 1)[0] == dest.rsplit("/", 1)[0])
return None if ok else "you cannot move this workflow there"
return None
def filter_listing(request, response, directory):
try:
items = json.loads(response.body)
except Exception:
return response
if not isinstance(items, list):
return response
w = hub.workspace
kind, key, perms = who(request)
base = Workspace.workflows_rel(directory) or ""
def rel_of(item):
p = item.get("path") if isinstance(item, dict) else (item[0] if isinstance(item, list) else item)
p = norm(p)
if p.startswith("workflows/"):
return p[len("workflows/"):]
return f"{base}/{p}" if base else p
kept = [it for it in items if w.visible(rel_of(it), kind, key, perms)]
return web.json_response(kept, headers={"Cache-Control": "no-store"})
@web.middleware
async def protect(request, handler):
path = request.path
@@ -490,17 +811,15 @@ def setup(server, store, hub):
path in ("/", "/index.html") or "text/html" in request.headers.get("Accept", ""))
if wants_page:
raise web.HTTPFound(f"/rigshare/login?next={path}")
# The header lets the ComfyUI page (see LOGIN_WATCH) go back to the login screen.
return web.json_response({"error": "RigShare: login required. API clients: send "
"'Authorization: Bearer <api key>'."}, status=401)
if path.startswith(("/userdata/workflows/", "/api/userdata/workflows/")) and not trusted(request.remote or ""):
# A restricted shared workflow's file follows its access list too.
rel = path.split("/userdata/", 1)[1].split("/move/", 1)[0]
room = hub.rooms.get("file:" + rel)
if room and room.acl:
role = room_access(request, room)
need = "view" if request.method in ("GET", "HEAD") else "edit"
if role is None or (need == "edit" and role != "edit"):
return web.json_response({"error": "RigShare: this workflow is restricted"}, status=403)
"'Authorization: Bearer <api key>'."}, status=401,
headers={"X-RigShare-Login": "required"})
userdata = parse_userdata(request)
if userdata and not trusted(request.remote or ""):
refusal = check_userdata(request, *userdata)
if refusal:
return web.json_response({"error": f"RigShare: {refusal}"}, status=403)
cfg = store.config.get("api_protection", {})
if cfg.get("enabled"):
path = request.path[4:] if request.path.startswith("/api/") else request.path
@@ -520,7 +839,37 @@ def setup(server, store, hub):
"details": "", "extra_info": {}}, "node_errors": {}},
status=403)
break
for method, prefix, need, why in PLUGIN_RULES:
if (method is None or request.method == method) and api_path(request).startswith(prefix):
if not trusted(request.remote or "") and not perms_for(request).get(need) and not perms_for(request).get("admin"):
return web.json_response({"error": f"RigShare: {why}"}, status=403)
break
refusal = await check_queue_ownership(request)
if refusal:
return web.json_response({"error": f"RigShare: {refusal}"}, status=403)
if userdata and userdata[0] == "save":
await request.read() # keep the body for the snapshot below (aiohttp caches it)
response = await handler(request)
# Remember who queued each prompt, for the Queue tab.
if request.method == "POST" and api_path(request) == "/prompt" and getattr(response, "status", 0) == 200:
try:
prompt_id = json.loads(response.body).get("prompt_id")
except (ValueError, TypeError, AttributeError):
prompt_id = None
hub.record_prompt(prompt_id, current_user(request), unquote(request.headers.get("X-RigShare-Workflow", "")))
# 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())
await hub.files_changed(userdata[1])
# 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])
# A file moved on disk: its live room follows it.
if userdata and userdata[0] == "move" and getattr(response, "status", 0) == 200:
await hub.path_moved(userdata[1], userdata[2])
if userdata and userdata[0] == "delete" and getattr(response, "status", 0) in (200, 204):
await hub.drop_rooms("file:" + userdata[1], display(request))
await hub.files_changed(userdata[1])
# Hide API templates: rewrite the template index ComfyUI serves.
if (store.config.get("hide_api_templates") and request.method == "GET"
and path.startswith("/templates/index") and path.endswith(".json")):
@@ -535,6 +884,11 @@ def setup(server, store, hub):
response = web.json_response(filter_templates(index), headers={"Cache-Control": "no-store"})
except Exception as e:
log.debug(f"[RigShare] could not filter templates: {e}")
# The ComfyUI page: never cached (an ended session must reach the redirect
# above), and watched, so a session that ends while it is open goes back
# to the login screen instead of showing API errors.
if request.method == "GET" and request.path in ("/", "/index.html") and store.config.get("require_login"):
response = watch_login(response)
# Sliding session: renew the cookie JWT when it is getting old.
cookie = request.cookies.get(COOKIE)
if cookie and isinstance(response, web.Response) and not response.prepared:
@@ -553,6 +907,11 @@ def setup(server, store, hub):
original_send_sync = server.send_sync
def send_sync(event, data, sid=None):
if event in ("execution_start", "execution_success", "execution_error", "execution_interrupted"):
try:
hub.run_event(event, data) # Performance: time the run
except Exception as e:
log.debug(f"[RigShare] could not time the run: {e}")
if sid is not None and store.config.get("broadcast_execution", True):
if event in BROADCAST_EVENTS or isinstance(event, int):
sid = None
+18
View File
@@ -51,6 +51,23 @@ def _gpus():
return out
def queue_items(server):
"""Running and pending prompts: [{id, number, status, nodes}], running first."""
try:
running, pending = server.prompt_queue.get_current_queue_volatile()
except Exception:
return None
items = []
for status, entries in (("running", running), ("pending", sorted(pending, key=lambda e: e[0]))):
for entry in entries:
try:
prompt = entry[2] if isinstance(entry[2], dict) else {}
items.append({"id": entry[1], "number": entry[0], "status": status, "nodes": len(prompt)})
except (IndexError, TypeError):
continue
return items
def _queue(server):
try:
running, pending = server.prompt_queue.get_current_queue_volatile()
@@ -76,4 +93,5 @@ def collect_stats(server=None):
stats["gpus"] = []
if server is not None:
stats["queue"] = _queue(server)
stats["queue_items"] = queue_items(server)
return stats
+51 -9
View File
@@ -24,7 +24,7 @@ SESSION_TTL = 30 * 24 * 3600 # a login lasts at most this long
TOKEN_TTL = 12 * 3600 # each JWT is short-lived...
TOKEN_REFRESH = 6 * 3600 # ...and re-issued while in use once older than this
ISSUER = "rigshare"
USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{2,32}$")
USERNAME_RE = re.compile(r"^(?!(?i:con|prn|aux|nul|com\d|lpt\d)(?:\.|$))[A-Za-z0-9_][A-Za-z0-9_.-]{0,30}[A-Za-z0-9_-]\Z") # no leading/trailing dot, no Windows device names
PERMS = ("edit", "queue", "manager", "admin")
API_KEY_PREFIX = "rs_"
@@ -47,13 +47,17 @@ 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,
# ComfyUI Manager in its own sidebar tab instead of the top-bar button.
"manager_tab": False,
# Remove templates that need paid API nodes from the template browser.
"hide_api_templates": False,
# Rooms nobody opened for this many days are forgotten.
"room_expiry_days": 14,
"snapshot_interval_sec": 300,
"snapshot_keep": 30,
"chat_history": 200,
"chat_keep": 0, # 0 keeps the whole chat; N trims it to the last N messages at startup
}
@@ -212,10 +216,14 @@ class Store:
_verify_password(password, _hash_password("x", b"0" * 16))
return None
def _name_taken(self, name, ignore=None):
"""Names differing only by case share one folder on Windows, so they count as the same."""
return any(u.casefold() == name.casefold() for u in self.users if u != ignore)
def create_user(self, username, password, display_name=None, perms=None):
if not USERNAME_RE.match(username or ""):
raise ValueError("Username must be 2-32 characters: letters, digits, _ . -")
if username in self.users:
if self._name_taken(username):
raise ValueError("Username already exists")
if len(password or "") < 8:
raise ValueError("Password must be at least 8 characters")
@@ -235,7 +243,7 @@ class Store:
raise KeyError(username)
if not USERNAME_RE.match(new_username):
raise ValueError("Username must be 2-32 characters: letters, digits, _ . -")
if new_username in self.users:
if self._name_taken(new_username, ignore=username):
raise ValueError("Username already exists")
with self._lock:
user = self.users.pop(username)
@@ -442,8 +450,38 @@ 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)
# ----- run log (Performance) -----------------------------------------
RUN_LOG_KEEP = 500
def runs(self):
"""Finished runs, oldest first: [{id, ms, end, result, workflow, user, name}]."""
if not hasattr(self, "_runs"):
self._runs = self._read("runs.json", [])
return self._runs
def add_run(self, run):
with self._lock:
runs = self.runs()
runs.append(run)
del runs[:-self.RUN_LOG_KEEP]
self._write("runs.json", runs)
def clear_runs(self, workflow):
"""Forget a workflow's timings (its ETA starts over); returns how many went."""
with self._lock:
runs = self.runs()
kept = [r for r in runs if r.get("workflow") != workflow]
gone = len(runs) - len(kept)
self._runs = kept
self._write("runs.json", kept)
return gone
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 +490,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 +508,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:
+488
View File
@@ -0,0 +1,488 @@
"""Workspace folders: a private space per account plus shared folders.
Inside ComfyUI's workflows directory::
users/<username>/... private: only its owner (and admins)
shared/<folder>/... shared folders: owner + optional access list
anything else common: everyone, with their account permissions
Paths handled here are relative to the *userdata* root, e.g.
``workflows/users/bob/flow.json``. ComfyUI decodes request paths twice, so
every path is fully URL-decoded and normalised before it is checked.
"""
import json
import os
import posixpath
import re
import shutil
import time
from urllib.parse import unquote
WORKFLOWS = "workflows"
FOLDER_NAME_RE = re.compile(r"^(?!(?i:con|prn|aux|nul|com\d|lpt\d)(?:\.|$))\w(?:[\w .()-]{0,62}[\w()-])?\Z") # no trailing dot/space, no Windows device names
ROLE_RANK = {None: 0, "view": 1, "edit": 2}
def full_unquote(value):
for _ in range(5):
if "%" not in value:
break
decoded = unquote(value)
if decoded == value:
break
value = decoded
return value
_WIN = os.name == "nt"
_SHORT_NAME_RE = re.compile(r"~\d") # Windows 8.3 alias of a long name (USERS~1)
_case_root = None # workflows directory used to restore on-disk casing (Windows only)
def _on_disk_case(parts, base):
"""Windows ignores case: map each segment to the casing it has on disk, so 'Users/Bob' is
judged as the same path it will open."""
out = []
for part in parts:
try:
names = os.listdir(base) if base else []
except OSError:
names = []
match = part if part in names else next((n for n in names if n.casefold() == part.casefold()), part)
out.append(match)
base = os.path.join(base, match) if base and match in names else None
return out
class InvalidPath(PermissionError):
"""A path Windows would read as a different one than the checks do: refused, never adjusted."""
def norm(path):
"""Decode and normalise a userdata-relative path ('' for the root).
A path with a segment Windows would read as something else is refused (InvalidPath), not
adjusted: a colon (drive prefix 'C:', NTFS stream), a 8.3 short name, or trailing dots or
spaces (Windows strips them, so 'users./bob' would open 'users/bob'). Casing is restored from
disk on Windows. Anything that still reaches the disk goes through safe_join().
"""
path = full_unquote(str(path or "")).replace("\\", "/")
if not path:
return ""
clean = posixpath.normpath("/" + path).lstrip("/")
parts = [p for p in clean.split("/") if p]
for p in parts:
if ":" in p or (_WIN and (_SHORT_NAME_RE.search(p) or p != p.rstrip(" ."))):
raise InvalidPath("Invalid path")
if _WIN and _case_root and parts:
lead = [WORKFLOWS] if parts[0].casefold() == WORKFLOWS else []
parts = lead + _on_disk_case(parts[len(lead):], _case_root)
return "/".join(parts)
def safe_join(root, *parts):
"""Join relative parts under root; refuse anything that resolves outside it."""
segments = [p for part in parts for p in str(part).replace("\\", "/").split("/") if p]
if any(":" in p or p in (".", "..") or (_WIN and p != p.rstrip(" .")) for p in segments):
raise PermissionError("Invalid path")
full = os.path.join(root, *segments)
base = os.path.realpath(root)
try:
inside = os.path.commonpath([base, os.path.realpath(full)]) == base
except ValueError: # different drives
inside = False
if not inside:
raise PermissionError("Invalid path")
return full
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
class Workspace:
def __init__(self, store, root):
self.store = store
self.root = root # absolute path of the workflows directory
global _case_root
_case_root = root
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)
# ----- classification -------------------------------------------------
@staticmethod
def area(rel):
"""Classify a path relative to the workflows dir.
Returns ("private", username) | ("shared", "shared/<name>") |
("users-root", None) | ("shared-root", None) | ("common", None).
"""
parts = [p for p in rel.split("/") if p]
if not parts:
return "common", None
if parts[0] == "users":
return ("private", parts[1]) if len(parts) > 1 else ("users-root", None)
if parts[0] == "shared":
return ("shared", "shared/" + parts[1]) if len(parts) > 1 else ("shared-root", None)
return "common", None
@staticmethod
def workflows_rel(userdata_path):
"""'workflows/a/b.json' -> 'a/b.json'; None if outside workflows."""
path = norm(userdata_path)
if path == WORKFLOWS:
return ""
if path.startswith(WORKFLOWS + "/"):
return path[len(WORKFLOWS) + 1:]
return None
# ----- access -------------------------------------------------------
def folder_role(self, folder, kind, key, perms):
can_edit = bool(perms.get("edit") or perms.get("admin"))
meta = self.folders.get(folder)
if perms.get("admin") or meta is None or not meta.get("acl"):
return "edit" if can_edit else "view"
if kind == "user" and key == meta.get("owner"):
return "edit" if can_edit else "view"
role = (meta["acl"].get("members") or {}).get(key) if kind == "user" else None
if role not in ("view", "edit"):
return None
return "edit" if role == "edit" and can_edit else "view"
def role(self, rel, kind, key, perms):
"""What someone may do with a path under workflows/: None, "view" or "edit"."""
area, owner = self.area(rel)
if area == "private":
if perms.get("admin") or (kind == "user" and key == owner):
return "edit" # your own space, even with a viewer account
return None
if area == "shared":
return self.folder_role(owner, kind, key, perms)
if area in ("users-root", "shared-root"):
return "view"
return "edit" if (perms.get("edit") or perms.get("admin")) else "view"
def can_manage(self, folder, kind, key, perms):
meta = self.folders.get(folder) or {}
return bool(perms.get("admin")) or (kind == "user" and key is not None and key == meta.get("owner"))
def can_delete(self, rel, kind, key, perms):
"""Deleting (or moving out of a folder) removes the file for others."""
area, owner = self.area(rel)
if perms.get("admin"):
return True
if area == "private":
return kind == "user" and key == owner
if area == "shared":
return self.can_manage(owner, kind, key, perms)
return False # common files: admins only
def can_move(self, src, dest, kind, key, perms):
if self.role(src, kind, key, perms) is None or self.role(dest, kind, key, perms) != "edit":
return False
if posixpath.dirname(src) == posixpath.dirname(dest):
return self.role(src, kind, key, perms) == "edit" # a rename in place
return self.can_delete(src, kind, key, perms)
# ----- listings -------------------------------------------------------
def visible(self, rel, kind, key, perms):
return self.role(rel, kind, key, perms) is not None
def _files(self, rel_dir):
base = safe_join(self.root, rel_dir) if rel_dir else self.root
out = []
if not os.path.isdir(base):
return out
for dirpath, dirnames, filenames in os.walk(base):
rel_here = os.path.relpath(dirpath, self.root).replace(os.sep, "/")
rel_here = "" if rel_here == "." else rel_here
if not rel_dir and rel_here.split("/")[0] in ("users", "shared"):
dirnames[:] = []
continue
if not rel_dir:
dirnames[:] = [d for d in dirnames if d not in ("users", "shared")]
for name in filenames:
if not name.endswith(".json"):
continue
full = os.path.join(dirpath, name)
rel = f"{rel_here}/{name}" if rel_here else name
out.append({"path": f"{WORKFLOWS}/{rel}", "name": rel[len(rel_dir) + 1:] if rel_dir else rel,
"modified": int(os.path.getmtime(full))})
out.sort(key=lambda f: f["name"].lower())
return out
def tree(self, kind, key, perms):
"""Everything this person may see, for the Files tab."""
can_edit = bool(perms.get("edit") or perms.get("admin"))
private = None
if kind == "user":
self.ensure_private(key)
private = {"path": f"users/{key}", "files": self._files(f"users/{key}")}
shared = []
shared_dir = os.path.join(self.root, "shared")
names = sorted(os.listdir(shared_dir), key=str.lower) if os.path.isdir(shared_dir) else []
for name in names:
folder = f"shared/{name}"
if not os.path.isdir(os.path.join(shared_dir, name)):
continue
role = self.folder_role(folder, kind, key, perms)
if role is None:
continue
meta = self.folders.get(folder) or {}
shared.append({"path": folder, "name": name, "owner": meta.get("owner"),
"restricted": bool(meta.get("acl")), "role": role,
"manage": self.can_manage(folder, kind, key, perms),
"files": self._files(folder)})
return {"me": key if kind == "user" else None, "private": private, "shared": shared,
"common": {"files": self._files(""), "role": "edit" if can_edit else "view"},
"can_create": can_edit}
# ----- browsing (Files tab) -------------------------------------------
def _abs(self, rel):
return safe_join(self.root, rel)
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[:-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)
if is_dir and area == "shared" and rel == folder:
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
def list_dir(self, rel, kind, key, perms):
"""One folder's content. "@shared" is the Shared root: the shared folders
plus the common top level. "users" (admins) lists everyone's folders.
Raises PermissionError / FileNotFoundError."""
can_edit = bool(perms.get("edit") or perms.get("admin"))
entries = []
if rel == "@shared":
shared_dir = self._abs("shared")
for name in (os.listdir(shared_dir) if os.path.isdir(shared_dir) else []):
path = "shared/" + name
if os.path.isdir(self._abs(path)) and self.folder_role(path, kind, key, perms):
entries.append(self._entry(path, name, True, kind, key, perms))
for name in os.listdir(self.root):
if name in ("users", "shared") or name.startswith("."):
continue
full = os.path.join(self.root, name)
if os.path.isdir(full) or name.endswith(".json"):
entries.append(self._entry(name, name, os.path.isdir(full), kind, key, perms))
role = "edit" if can_edit else "view"
can_mkdir = can_edit and kind == "user"
else:
rel = norm(rel)
if rel == "users" and not perms.get("admin"):
raise PermissionError("You do not have access to this folder")
role = self.role(rel, kind, key, perms)
if role is None:
raise PermissionError("You do not have access to this folder")
if kind == "user" and rel == "users/" + str(key):
self.ensure_private(key)
base = self._abs(rel)
if not os.path.isdir(base):
raise FileNotFoundError("This folder no longer exists")
for name in os.listdir(base):
if name.startswith("."):
continue
child = rel + "/" + name if rel else name
full = os.path.join(base, name)
if not (os.path.isdir(full) or name.endswith(".json")):
continue
if self.role(child, kind, key, perms) is None:
continue
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()))
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."""
name = (name or "").strip()
if not FOLDER_NAME_RE.match(name) or name in (".", ".."):
raise ValueError("Folder names: up to 64 letters, digits, spaces and . _ - ( )")
if parent == "@shared":
if kind != "user" or not (perms.get("edit") or perms.get("admin")):
raise PermissionError("You need the Edit permission to create shared folders")
return self.create_folder(name, key)
parent = norm(parent)
if parent in ("", "users", "shared") or self.role(parent, kind, key, perms) != "edit":
raise PermissionError("You cannot create a folder here")
path = parent + "/" + name
if os.path.exists(self._abs(path)):
raise ValueError("A folder with that name already exists")
os.makedirs(self._abs(path))
return path
def _top_level(self, rel):
area, folder = self.area(rel)
return (area == "shared" and rel == folder) or (area == "private" and rel.count("/") < 2) \
or rel in ("users", "shared")
def rename_dir(self, rel, name, kind, key, perms):
rel = norm(rel)
area, folder = self.area(rel)
if area == "shared" and rel == folder:
if not self.can_manage(folder, kind, key, perms):
raise PermissionError("Only the folder's owner or an admin can rename it")
return self.rename_folder(folder, name)
name = (name or "").strip()
if not FOLDER_NAME_RE.match(name) or name in (".", ".."):
raise ValueError("Folder names: up to 64 letters, digits, spaces and . _ - ( )")
parent = posixpath.dirname(rel)
if self._top_level(rel) or not self.can_delete(rel, kind, key, perms) \
or self.role(parent, kind, key, perms) != "edit":
raise PermissionError("You cannot rename this folder")
new = parent + "/" + name if parent else name
if os.path.exists(self._abs(new)):
raise ValueError("A folder with that name already exists")
os.rename(self._abs(rel), self._abs(new))
return new
def move_dir(self, rel, dest_parent, kind, key, perms):
rel, dest_parent = norm(rel), ("" if dest_parent == "@shared" else norm(dest_parent))
if self._top_level(rel):
raise PermissionError("Top-level folders cannot be moved")
if dest_parent == rel or dest_parent.startswith(rel + "/"):
raise ValueError("A folder cannot be moved into itself")
if dest_parent in ("users", "shared") or not self.can_delete(rel, kind, key, perms) \
or self.role(dest_parent, kind, key, perms) != "edit":
raise PermissionError("You cannot move this folder there")
new = dest_parent + "/" + posixpath.basename(rel) if dest_parent else posixpath.basename(rel)
if os.path.exists(self._abs(new)):
raise ValueError("A folder with that name already exists there")
os.rename(self._abs(rel), self._abs(new))
return new
def delete_dir(self, rel, kind, key, perms):
rel = norm(rel)
area, folder = self.area(rel)
if area == "private" and rel.count("/") < 2 or rel in ("", "users", "shared"):
raise PermissionError("This folder cannot be deleted")
if not self.can_delete(rel, kind, key, perms):
raise PermissionError("Only the folder's owner or an admin can delete it")
has_files = any(files for _, _, files in os.walk(self._abs(rel)))
own_private = area == "private" and kind == "user" and folder == key
if has_files and not own_private:
raise ValueError("Move or delete the workflows inside it first")
shutil.rmtree(self._abs(rel), ignore_errors=True)
if area == "shared" and rel == folder:
self.folders.pop(folder, None)
self.save()
# ----- changes ------------------------------------------------------
def ensure_private(self, username):
os.makedirs(os.path.join(self.root, "users", username), exist_ok=True)
def create_folder(self, name, owner):
name = (name or "").strip()
if not FOLDER_NAME_RE.match(name) or name in (".", ".."):
raise ValueError("Folder names: up to 64 letters, digits, spaces and . _ - ( )")
path = safe_join(self.root, "shared", name)
if os.path.exists(path):
raise ValueError("A folder with that name already exists")
os.makedirs(path)
self.folders[f"shared/{name}"] = {"owner": owner, "acl": None, "created": int(time.time())}
self.save()
return f"shared/{name}"
def rename_folder(self, folder, new_name):
new_name = (new_name or "").strip()
if not FOLDER_NAME_RE.match(new_name):
raise ValueError("Folder names: up to 64 letters, digits, spaces and . _ - ( )")
new_folder = f"shared/{new_name}"
src = safe_join(self.root, folder)
dst = safe_join(self.root, "shared", new_name)
if os.path.exists(dst):
raise ValueError("A folder with that name already exists")
os.rename(src, dst)
self.folders[new_folder] = self.folders.pop(folder, {"owner": None, "acl": None})
self.save()
return new_folder
def delete_folder(self, folder):
path = safe_join(self.root, folder)
if self._files(folder):
raise ValueError("Move or delete the workflows inside it first")
shutil.rmtree(path, ignore_errors=True)
self.folders.pop(folder, None)
self.save()
def set_acl(self, folder, acl, fallback_owner=None):
meta = self.folders.setdefault(folder, {"owner": None, "acl": None, "created": int(time.time())})
meta["acl"] = acl
if acl and not meta.get("owner"):
meta["owner"] = fallback_owner
self.save()
return meta
def rename_user(self, old, new):
src = os.path.join(self.root, "users", old)
dst = os.path.join(self.root, "users", new)
if os.path.isdir(src) and not os.path.exists(dst):
os.rename(src, dst)
for meta in self.folders.values():
if meta.get("owner") == old:
meta["owner"] = new
members = (meta.get("acl") or {}).get("members") or {}
if old in members:
members[new] = members.pop(old)
self.save()
def forget_member(self, username):
for meta in self.folders.values():
members = (meta.get("acl") or {}).get("members") or {}
members.pop(username, None)
self.save()
+51
View File
@@ -0,0 +1,51 @@
// Files browser tabs: My files, Shared, and All for admins.
import { test } from "node:test";
import assert from "node:assert/strict";
// Just enough DOM for the browser to build its elements.
class FakeNode {
constructor(tag) { this.tag = tag; this.children = []; this.style = {}; this.attrs = {}; this.className = ""; }
append(...c) { this.children.push(...c); }
replaceChildren(...c) { this.children = c; }
addEventListener() {}
setAttribute(k, v) { this.attrs[k] = v; }
querySelector() { return new FakeNode("div"); }
get isConnected() { return false; }
}
globalThis.Node ??= FakeNode;
globalThis.document ??= { createElement: (t) => new FakeNode(t), createTextNode: (t) => Object.assign(new FakeNode("#text"), { text: t }) };
const { FilesBrowser } = await import("../../web/rigshare/files.js");
const browser = (admin, start) => new FilesBrowser({
app: {}, sync: {}, mode: "open", start,
client: { self: { key: "bob" }, perms: { admin }, users: [], rooms: [], on() {}, emit() {} },
});
test("tabs: My files and Shared for everyone, All for admins", () => {
assert.deepEqual(browser(false).tabs.map((t) => t.label), ["My files", "Shared"]);
assert.deepEqual(browser(true).tabs.map((t) => t.label), ["My files", "Shared", "All"]);
});
test("starts in My files and picks the tab a folder belongs to", () => {
const b = browser(false);
assert.equal(b.tab, "mine");
assert.equal(b.path, "users/bob");
assert.equal(browser(false, "shared/Team").tab, "shared");
assert.equal(browser(false, "renders").tab, "shared");
assert.equal(browser(true, "users/alice").tab, "all");
assert.equal(browser(true, "").tab, "all");
});
test("people without All never start outside their tabs", () => {
for (const start of ["", "users", "users/alice/x"]) {
const b = browser(false, start);
assert.ok(["users/bob", "@shared"].includes(b.path), `${start} -> ${b.path}`);
}
});
test("breadcrumbs start at the tab", () => {
assert.deepEqual(browser(false, "users/bob/renders/old").trail().map((c) => c.label), ["My files", "renders", "old"]);
assert.deepEqual(browser(false, "shared/Team/sub").trail().map((c) => c.label), ["Shared", "Team", "sub"]);
assert.deepEqual(browser(true, "users/alice").trail().map((c) => c.label), ["All", "users", "alice"]);
assert.equal(browser(false, "shared/Team").label, "Shared / Team");
});
+40
View File
@@ -0,0 +1,40 @@
// Workflow patch logic on the client (mirror of rigshare_core/graphdoc.py).
import { test } from "node:test";
import assert from "node:assert/strict";
import { applyPatch, reconcile, linkId } from "../../web/rigshare/graphdoc.js";
const node = (id) => ({ id, type: "T", inputs: [{ name: "a", link: null }, { name: "b", link: null }], outputs: [{ name: "o", links: [] }] });
const doc = (links = []) => reconcile({ last_node_id: 3, last_link_id: links.length, nodes: [1, 2, 3].map(node), links, groups: [], extra: {} });
test("upserts and removes nodes without mutating the input", () => {
const base = doc();
const moved = { ...structuredClone(base.nodes[0]), pos: [5, 5] };
const out = applyPatch(base, { nodes: [moved, node(4)], removed: [2] });
assert.deepEqual(out.nodes.map((n) => n.id), [1, 3, 4]);
assert.equal(base.nodes.length, 3);
});
test("links merge by id instead of replacing each other", () => {
const out = applyPatch(doc([[1, 1, 0, 2, 0, "X"]]), { links: { upsert: [[2, 3, 0, 2, 1, "X"]] } });
assert.deepEqual(out.links.map(linkId).sort(), [1, 2]);
assert.deepEqual(out.nodes[1].inputs.map((s) => s.link), [1, 2]);
});
test("last ids never go backwards", () => {
const base = { ...doc(), last_node_id: 10 };
assert.equal(applyPatch(base, { set: { last_node_id: 4 } }).last_node_id, 10);
});
test("reconcile drops dangling links and keeps the newest link per input", () => {
const d = doc();
d.links = [[1, 1, 0, 2, 0, "X"], [2, 3, 0, 2, 0, "X"], [3, 9, 0, 2, 1, "X"]];
reconcile(d);
assert.deepEqual(d.links.map(linkId), [2]);
assert.equal(d.nodes[1].inputs[0].link, 2);
assert.deepEqual(d.nodes[2].outputs[0].links, [2]);
});
test("full replaces the document", () => {
const out = applyPatch(doc(), { full: { nodes: [], links: [] } });
assert.deepEqual(out, { nodes: [], links: [] });
});
+164
View File
@@ -0,0 +1,164 @@
// Client sync: diffs round-trip, and loading a graph never leaks into the old tab's room.
import { test } from "node:test";
import assert from "node:assert/strict";
globalThis.window ??= new EventTarget();
// RoomSync polls with setInterval; let those timers not keep the test process alive.
const nativeSetInterval = globalThis.setInterval;
globalThis.setInterval = (...args) => { const t = nativeSetInterval(...args); t.unref?.(); return t; };
globalThis.performance ??= { now: () => Date.now() };
const { RoomSync, snapshotOf, diffSnapshots, isPrivatePath } = await import("../../web/rigshare/sync.js");
const { applyPatch, reconcile } = await import("../../web/rigshare/graphdoc.js");
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const node = (id) => ({ id, type: "T", pos: [0, 0], inputs: [{ name: "a", link: null }, { name: "b", link: null }], outputs: [{ name: "o", links: [] }] });
test("diff then apply reproduces the edited workflow", () => {
let seed = 3;
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
for (let t = 0; t < 300; t++) {
const base = reconcile({ last_node_id: 5, last_link_id: 2, nodes: [1, 2, 3, 4, 5].map(node),
links: [[1, 1, 0, 2, 0, "X"], [2, 3, 0, 4, 1, "X"]], groups: [{ id: 1, title: "g" }], extra: { a: 1 }, version: 0.4 });
const cur = structuredClone(base);
if (rnd() < 0.5) cur.nodes[0].pos = [rnd(), rnd()];
if (rnd() < 0.4) { cur.last_node_id++; cur.nodes.push(node(cur.last_node_id)); }
if (rnd() < 0.4) { cur.last_link_id++; cur.links.push([cur.last_link_id, cur.nodes.at(-1).id, 0, 5, 0, "X"]); }
if (rnd() < 0.3) cur.links = cur.links.filter((l) => l[0] !== 1);
if (rnd() < 0.3) cur.groups[0].title = "renamed";
if (rnd() < 0.2) cur.extra.b = 2;
if (rnd() < 0.15) cur.nodes = cur.nodes.filter((n) => n.id !== 3);
reconcile(cur);
const patch = diffSnapshots(snapshotOf(base), snapshotOf(cur));
const out = patch ? applyPatch(base, patch) : base;
assert.equal(diffSnapshots(snapshotOf(out), snapshotOf(cur)), null);
}
});
test("opening a file never overwrites the shared workflow of the previous tab", async () => {
const graphA = { nodes: [node(1), node(2)], links: [], extra: {}, version: 0.4 };
const fromDisk = { nodes: [{ id: 1, type: "ReActorFaceSwap" }, { id: 2, type: "LoadImage" }, { id: 3, type: "SaveImage" }], links: [], extra: {}, version: 0.4 };
let current = structuredClone(graphA);
const tabA = { path: "workflows/shared.json", filename: "shared.json", isTemporary: false };
const store = { activeWorkflow: tabA, openWorkflows: [tabA] };
const sent = [];
const client = Object.assign(new EventTarget(), {
online: true, perms: { edit: true }, rooms: [], users: [],
on(type, fn) { this.addEventListener(type, (e) => fn(e.detail)); },
emit(type, detail) { this.dispatchEvent(new CustomEvent(type, { detail })); },
send(m) { sent.push(m); return true; },
});
const app = {
extensionManager: { workflow: store }, canvas: null,
rootGraph: { serialize: () => structuredClone(current), extra: {}, getNodeById: () => null },
// Like ComfyUI: swap the graph, yield, then switch to a new tab.
async loadGraphData(data, clean, restore, target) {
current = structuredClone(data);
await sleep(30);
if (typeof target === "string" || target == null) {
const tab = { path: `workflows/${target}`, filename: target, isTemporary: true };
store.openWorkflows.push(tab);
store.activeWorkflow = tab;
}
},
};
const api = new EventTarget();
const sync = new RoomSync(app, api, client);
sync.tick();
client.emit("room", { room: `file:${tabA.path}`, version: 1, doc: graphA, role: "edit", manage: true });
await sleep(400);
sent.length = 0;
const loading = app.loadGraphData(fromDisk, true, true, "face swap.json");
for (let i = 0; i < 5; i++) { api.dispatchEvent(new Event("graphChanged")); sync.checkLocal(); sync.tick(); await sleep(5); }
await loading;
for (let i = 0; i < 5; i++) { api.dispatchEvent(new Event("graphChanged")); sync.tick(); await sleep(20); }
const leaked = sent.filter((m) => m.type === "graph" && m.room === `file:${tabA.path}`);
assert.equal(leaked.length, 0, "the previous tab's room must not receive the new graph");
});
test("live follows folders: saved files outside private folders only", () => {
const store = { activeWorkflow: null, openWorkflows: [] };
const client = Object.assign(new EventTarget(), { online: false, perms: {}, rooms: [], users: [], on() {}, send() { return true; } });
const app = { extensionManager: { workflow: store }, loadGraphData: async () => {} };
const sync = new RoomSync(app, new EventTarget(), client);
const tab = (path, isTemporary = false) => ({ path, filename: path.split("/").pop(), isTemporary });
assert.equal(sync.roomFor(tab("workflows/flow.json"))?.key, "file:workflows/flow.json");
assert.equal(sync.roomFor(tab("workflows/shared/Team/a.json"))?.key, "file:workflows/shared/Team/a.json");
assert.equal(sync.roomFor(tab("workflows/users/bob/a.json")), null);
assert.equal(sync.privacyOf(tab("workflows/users/bob/a.json")), "private");
assert.equal(sync.roomFor(tab("workflows/Unsaved Workflow.json", true)), null);
assert.equal(sync.privacyOf(tab("workflows/Unsaved Workflow.json", true)), "unsaved");
assert.ok(isPrivatePath("workflows/users") && !isPrivatePath("workflows/usersx/a.json"));
});
test("a file renamed by someone else re-points the open tab and rejoins its room", async () => {
class Wf {
constructor(path) { this.updatePath(path); this.isTemporary = false; }
updatePath(path) { this.path = path; this.filename = path.split("/").pop(); }
async rename() { throw new Error("the file already moved: must not call the server"); }
}
const wf = new Wf("workflows/shared/a.json");
const other = new Wf("workflows/other.json");
const store = {
activeWorkflow: wf, openWorkflows: [wf, other],
async renameWorkflow(w, path) { await w.rename(path); }, // like ComfyUI's store
};
const sent = [];
const client = Object.assign(new EventTarget(), {
online: true, perms: { edit: true }, rooms: [], users: [],
on(type, fn) { this.addEventListener(type, (e) => fn(e.detail)); },
emit(type, detail) { this.dispatchEvent(new CustomEvent(type, { detail })); },
send(m) { sent.push(m); return true; },
});
const app = { extensionManager: { workflow: store }, canvas: null, loadGraphData: async () => {},
rootGraph: { serialize: () => ({ nodes: [], links: [], extra: {} }), extra: {}, getNodeById: () => null } };
const sync = new RoomSync(app, new EventTarget(), client);
sync.tick();
assert.equal(sent.at(-1).room, "file:workflows/shared/a.json");
client.emit("path_moved", { from: "workflows/shared", to: "workflows/shared2" });
await sync.busy;
assert.equal(wf.path, "workflows/shared2/a.json");
assert.equal(other.path, "workflows/other.json");
assert.ok(!Object.hasOwn(wf, "rename"), "the real rename is restored");
assert.equal(sent.at(-1).room, "file:workflows/shared2/a.json");
client.emit("path_closed", { from: "workflows/shared2/a.json" });
sync.tick();
assert.equal(sent.at(-1).room, null, "moved out of reach: the tab leaves the room");
});
test("deleting a folder closes its tabs, the active one through ComfyUI's close", async () => {
const a = { path: "workflows/team/a.json", isTemporary: false, isModified: true };
const b = { path: "workflows/team/sub/b.json", isTemporary: false };
const other = { path: "workflows/other.json", isTemporary: false };
const store = {
activeWorkflow: a, openWorkflows: [a, b, other],
closeWorkflow(wf) { this.openWorkflows = this.openWorkflows.filter((w) => w !== wf); },
};
const commands = [];
const client = Object.assign(new EventTarget(), {
online: true, perms: { edit: true }, rooms: [], users: [],
on(type, fn) { this.addEventListener(type, (e) => fn(e.detail)); },
emit(type, detail) { this.dispatchEvent(new CustomEvent(type, { detail })); },
send() { return true; },
});
const app = {
extensionManager: { workflow: store, command: { async execute(id) {
commands.push([id, store.activeWorkflow.isModified]);
store.closeWorkflow(store.activeWorkflow);
store.activeWorkflow = store.openWorkflows[0];
} } },
canvas: null, loadGraphData: async () => {},
rootGraph: { serialize: () => ({ nodes: [], links: [], extra: {} }), extra: {}, getNodeById: () => null },
};
const sync = new RoomSync(app, new EventTarget(), client);
const toasts = [];
client.on("toast", (t) => toasts.push(t));
await sync.closeTabsUnder("workflows/team");
assert.deepEqual(store.openWorkflows, [other]);
assert.deepEqual(commands, [["Workspace.CloseWorkflow", false]], "no unsaved-changes prompt");
client.emit("room_closed", { room: "file:workflows/team/a.json", name: "a" });
assert.equal(toasts.length, 0, "our own delete: no private-copy notice");
});
+21
View File
@@ -0,0 +1,21 @@
import asyncio
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@pytest.fixture
def run():
"""Run a coroutine to completion (no pytest-asyncio dependency)."""
def _run(coro):
return asyncio.run(coro)
return _run
@pytest.fixture
def store(tmp_path):
from helpers import Store
return Store(str(tmp_path))
+237
View File
@@ -0,0 +1,237 @@
"""Test helpers: a RigShare server without ComfyUI.
``rig_server()`` starts a real aiohttp app with RigShare's routes, middleware
and hub, plus a small stand-in for ComfyUI's /userdata API backed by a temp
folder, so HTTP and websocket behaviour can be tested end to end.
"""
import contextlib
import glob
import json
import os
import sys
import tempfile
from urllib.parse import quote, unquote
from aiohttp import web, ClientSession, CookieJar
from aiohttp.test_utils import TestServer
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from rigshare_core.store import Store # noqa: E402
from rigshare_core.hub import Hub # noqa: E402
from rigshare_core.workspace import Workspace # noqa: E402
from rigshare_core import routes # noqa: E402
PASSWORD = "longpassword"
def enc(path):
"""Encode a userdata path the way ComfyUI's frontend does."""
return quote(path, safe="")
class FakeQueue:
"""ComfyUI's PromptQueue, as far as RigShare reads it."""
def __init__(self):
self.running, self.pending, self.number = [], [], 0
def get_current_queue_volatile(self):
return list(self.running), list(self.pending)
history = {}
def get_history(self, prompt_id=None, max_items=None, offset=-1, map_function=None):
items = list(self.history.items())[-(max_items or len(self.history)):] if self.history else []
return {k: (map_function(v) if map_function else v) for k, v in items}
class FakePromptServer:
def __init__(self):
self.app = web.Application()
self.sent = []
self.prompt_queue = FakeQueue()
def send_sync(self, event, data, sid=None):
self.sent.append((event, sid))
def comfy_routes(user_root, queue=None):
"""Minimal ComfyUI /userdata routes for the single default user, plus the queue."""
def resolve(name):
if "%" in name:
name = unquote(name)
path = os.path.abspath(os.path.join(user_root, name))
return path if os.path.commonpath((user_root, path)) == user_root else None
async def listing(request):
base = resolve(request.query["dir"])
out = []
for full in glob.glob(os.path.join(glob.escape(base), "**", "*"), recursive=True):
if os.path.isfile(full):
out.append({"path": os.path.relpath(full, base).replace(os.sep, "/"), "size": 1, "modified": 1})
return web.json_response(out)
async def get(request):
path = resolve(request.match_info["file"])
return web.FileResponse(path) if path and os.path.exists(path) else web.Response(status=404)
async def post(request):
path = resolve(request.match_info["file"])
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(await request.read())
return web.json_response(os.path.relpath(path, user_root))
async def delete(request):
os.remove(resolve(request.match_info["file"]))
return web.Response(status=204)
async def move(request):
src, dst = resolve(request.match_info["file"]), resolve(request.match_info["dest"])
os.makedirs(os.path.dirname(dst), exist_ok=True)
os.rename(src, dst)
return web.json_response(os.path.relpath(dst, user_root))
async def ok(request):
return web.json_response({"ok": True})
async def prompt(request):
if request.method != "POST" or queue is None:
return web.json_response({"ok": True})
body = await request.json() if request.body_exists else {}
queue.number += 1
prompt_id = f"p{queue.number}"
queue.pending.append((queue.number, prompt_id, (body or {}).get("prompt") or {"1": {}}, {}, []))
return web.json_response({"prompt_id": prompt_id, "number": queue.number, "node_errors": {}})
async def queue_route(request):
body = await request.json() if request.body_exists else {}
if queue is not None:
gone = set((body or {}).get("delete") or [])
queue.pending = [e for e in queue.pending if e[1] not in gone]
if (body or {}).get("clear"):
queue.pending = []
return web.Response(status=200)
table = web.RouteTableDef()
for prefix in ("", "/api"):
table.get(prefix + "/userdata")(listing)
table.get(prefix + "/userdata/{file}")(get)
table.post(prefix + "/userdata/{file}")(post)
table.delete(prefix + "/userdata/{file}")(delete)
table.post(prefix + "/userdata/{file}/move/{dest}")(move)
table.route("*", prefix + "/prompt")(prompt)
table.post(prefix + "/queue")(queue_route)
table.post(prefix + "/interrupt")(ok)
table.post(prefix + "/free")(ok)
table.route("*", prefix + "/manager/{tail:.*}")(ok)
table.route("*", prefix + "/customnode/{tail:.*}")(ok)
return table
class Rig:
"""A running test server plus convenience helpers."""
def __init__(self, server, store, hub, prompt_server, user_root):
self.server, self.store, self.hub, self.ps, self.user_root = server, store, hub, prompt_server, user_root
self.base = f"http://{server.host}:{server.port}"
self.http = None
self.headers = {}
def url(self, path):
return self.base + path
async def setup_admin(self, username="boss"):
r = await self.http.post(self.url("/rigshare/api/setup"), json={"username": username, "password": PASSWORD})
assert r.status == 200, await r.text()
self.headers[username] = {"Authorization": "Bearer " + (await r.json())["token"]}
return self.headers[username]
async def add_user(self, username, **perms):
admin = next(iter(self.headers.values()))
r = await self.http.post(self.url("/rigshare/api/users"), headers=admin,
json={"username": username, "password": PASSWORD, "perms": perms})
assert r.status == 200, await r.text()
r = await self.http.post(self.url("/rigshare/api/login"), json={"username": username, "password": PASSWORD})
self.headers[username] = {"Authorization": "Bearer " + (await r.json())["token"]}
return self.headers[username]
async def ws(self, username):
session = ClientSession()
sock = await session.ws_connect(self.url("/rigshare/ws"), headers=self.headers[username])
await sock.send_json({"type": "hello"})
await recv_until(sock, "welcome")
return Socket(session, sock)
class Socket:
def __init__(self, session, sock):
self.session, self.sock = session, sock
async def send(self, message):
await self.sock.send_json(message)
async def until(self, kind, timeout=2.0):
return await recv_until(self.sock, kind, timeout)
async def drain(self, timeout=0.3):
return await drain(self.sock, timeout)
async def close(self):
await self.sock.close()
await self.session.close()
async def recv_until(sock, kind, timeout=2.0):
import asyncio
loop = asyncio.get_running_loop()
end = loop.time() + timeout
while True:
msg = await asyncio.wait_for(sock.receive(), max(0.01, end - loop.time()))
data = json.loads(msg.data)
if data.get("type") == kind:
return data
async def drain(sock, timeout=0.3):
import asyncio
out = []
try:
while True:
msg = await asyncio.wait_for(sock.receive(), timeout)
out.append(json.loads(msg.data))
except asyncio.TimeoutError:
return out
@contextlib.asynccontextmanager
async def rig_server(config=None, workspace=True, setup=None):
data = tempfile.mkdtemp()
user_root = tempfile.mkdtemp()
os.makedirs(os.path.join(user_root, "workflows"))
store = Store(data)
# Tests connect from 127.0.0.1: nothing may be trusted, or checks are skipped.
store.update_config({"api_protection": {"enabled": True, "trusted_ips": []}, **(config or {})})
ps = FakePromptServer()
hub = Hub(store, ps)
if workspace:
hub.workspace = Workspace(store, os.path.join(user_root, "workflows"))
routes.setup(ps, store, hub)
ps.app.add_routes(comfy_routes(user_root, ps.prompt_queue))
if setup:
setup(ps.app)
server = TestServer(ps.app, host="127.0.0.1")
await server.start_server()
rig = Rig(server, store, hub, ps, user_root)
# unsafe=True: keep cookies for the 127.0.0.1 test host (browsers do).
async with ClientSession(cookie_jar=CookieJar(unsafe=True)) as http:
rig.http = http
try:
yield rig
finally:
await server.close()
+41
View File
@@ -0,0 +1,41 @@
"""Chat history: append-only log paged by sequence number."""
import json
import os
from rigshare_core.chatlog import ChatLog
def test_pages_back_through_the_whole_history(tmp_path):
chat = ChatLog(str(tmp_path))
for i in range(130):
assert chat.append({"text": f"m{i}", "ts": i})["seq"] == i
page, more = chat.page(limit=50)
assert [m["text"] for m in page] == [f"m{i}" for i in range(80, 130)] and more
seen = page
while more:
page, more = chat.page(before=seen[0]["seq"], limit=50)
seen = page + seen
assert [m["seq"] for m in seen] == list(range(130)), "every message exactly once, in order"
assert chat.page(before=0) == ([], False)
def test_survives_restart_a_torn_line_and_imports_old_chat(tmp_path):
folder = str(tmp_path)
chat = ChatLog(folder, legacy=[{"text": "old 1"}, {"text": "old 2"}])
chat.append({"text": "new"})
with open(os.path.join(folder, "chat.jsonl"), "ab") as f:
f.write(b'{"text": "torn') # the server died mid-write
chat = ChatLog(folder, legacy=[{"text": "never imported twice"}])
assert [m["text"] for m in chat.page()[0]] == ["old 1", "old 2", "new"]
chat.append({"text": "after"})
assert chat.page()[0][-1] == {"text": "after", "seq": 3}
def test_optional_trim_and_unicode(tmp_path):
chat = ChatLog(str(tmp_path))
for i in range(10):
chat.append({"text": f"héllo {i} 🎉"})
chat = ChatLog(str(tmp_path), keep=4)
assert [m["text"] for m in chat.page()[0]] == [f"héllo {i} 🎉" for i in range(6, 10)]
assert len(chat) == 4
+150
View File
@@ -0,0 +1,150 @@
"""Workflow patches: apply, link repair, conflict renumbering, JS parity."""
import copy
import json
import random
import shutil
import subprocess
from pathlib import Path
import pytest
from helpers import ROOT
from rigshare_core.graphdoc import apply_patch, reconcile, remap_conflicts, link_id, link_ends
def node(nid, n_in=2, n_out=1):
return {"id": nid, "type": "T", "pos": [0, 0],
"inputs": [{"name": f"i{i}", "link": None} for i in range(n_in)],
"outputs": [{"name": f"o{j}", "links": []} for j in range(n_out)]}
def doc_with(n=3, links=()):
doc = {"last_node_id": n, "last_link_id": len(links), "nodes": [node(i + 1) for i in range(n)],
"links": [list(l) for l in links], "groups": [], "extra": {}, "version": 0.4}
return reconcile(doc)
def assert_consistent(doc):
ids = {n["id"] for n in doc["nodes"]}
links = {link_id(l): l for l in doc["links"]}
assert len(links) == len(doc["links"]), "duplicate link ids"
assert len(ids) == len(doc["nodes"]), "duplicate node ids"
for l in doc["links"]:
origin, _, target, _ = link_ends(l)
assert origin in ids and target in ids, "dangling link"
for n in doc["nodes"]:
for i, slot in enumerate(n["inputs"]):
if slot["link"] is not None:
_, _, target, target_slot = link_ends(links[slot["link"]])
assert (target, target_slot) == (n["id"], i)
def test_upsert_and_remove_nodes():
doc = doc_with(3)
moved = copy.deepcopy(doc["nodes"][0]); moved["pos"] = [5, 5]
out = apply_patch(doc, {"nodes": [moved, node(4)], "removed": [2]})
assert [n["id"] for n in out["nodes"]] == [1, 3, 4]
assert out["nodes"][0]["pos"] == [5, 5]
assert doc["nodes"][0]["pos"] == [0, 0], "input document must not be mutated"
def test_links_merge_by_id():
doc = doc_with(3, [(1, 1, 0, 2, 0, "X")])
out = apply_patch(doc, {"links": {"upsert": [[2, 3, 0, 2, 1, "X"]]}})
assert sorted(link_id(l) for l in out["links"]) == [1, 2], "a new link must not erase another"
def test_max_keys_never_go_backwards():
doc = doc_with(3)
doc["last_node_id"] = 10
assert apply_patch(doc, {"set": {"last_node_id": 4}})["last_node_id"] == 10
def test_reconcile_drops_dangling_and_duplicate_inputs():
doc = doc_with(3)
doc["links"] = [[1, 1, 0, 2, 0, "X"], [2, 3, 0, 2, 0, "X"], [3, 9, 0, 2, 1, "X"]]
reconcile(doc)
assert [link_id(l) for l in doc["links"]] == [2], "newest link on a slot wins, dangling dropped"
assert doc["nodes"][1]["inputs"][0]["link"] == 2
assert doc["nodes"][2]["outputs"][0]["links"] == [2]
assert_consistent(doc)
def test_remap_simultaneous_adds():
base = doc_with(2)
def add(target_slot):
return {"nodes": [node(3)], "added": [3],
"links": {"upsert": [[1, 3, 0, 2, target_slot, "X"]], "added": [1]},
"set": {"last_node_id": 3, "last_link_id": 1}}
server = base
pa, changed_a = remap_conflicts(server, add(0)); server = apply_patch(server, pa)
pb, changed_b = remap_conflicts(server, add(1)); server = apply_patch(server, pb)
assert not changed_a and changed_b
assert sorted(n["id"] for n in server["nodes"]) == [1, 2, 3, 4]
assert sorted(link_id(l) for l in server["links"]) == [1, 2]
assert_consistent(server)
def test_remap_leaves_updates_of_existing_nodes_alone():
doc = doc_with(3)
update = copy.deepcopy(doc["nodes"][0]); update["pos"] = [1, 1]
patch, changed = remap_conflicts(doc, {"nodes": [update]})
assert not changed and patch["nodes"][0]["id"] == 1
def test_concurrent_edits_converge():
rnd = random.Random(1)
for _ in range(100):
server = doc_with(4, [(1, 1, 0, 2, 0, "X")])
observer = copy.deepcopy(server)
patches = []
for _ in range(2): # two people, same base, same instant
nid, lid = server["last_node_id"] + 1, server["last_link_id"] + 1
target = rnd.choice([n["id"] for n in server["nodes"]])
patches.append({"nodes": [node(nid)], "added": [nid],
"links": {"upsert": [[lid, nid, 0, target, rnd.randint(0, 1), "X"]], "added": [lid]},
"set": {"last_node_id": nid, "last_link_id": lid}})
for p in patches:
p2, _ = remap_conflicts(server, p)
server = apply_patch(server, p2)
observer = apply_patch(observer, p2)
assert_consistent(server)
assert len(server["nodes"]) == 6
assert json.dumps(server, sort_keys=True) == json.dumps(observer, sort_keys=True)
@pytest.mark.skipif(not shutil.which("node"), reason="node is not installed")
def test_javascript_mirror_matches(tmp_path):
"""graphdoc.js must produce exactly the same documents as graphdoc.py."""
rnd = random.Random(7)
cases = []
for _ in range(60):
doc = doc_with(5, [(1, 1, 0, 2, 0, "X"), (2, 3, 0, 4, 1, "X")])
patches, cur = [], doc
for _ in range(5):
ids = [n["id"] for n in cur["nodes"]]
p = {}
if rnd.random() < .5:
n = copy.deepcopy(rnd.choice(cur["nodes"])); n["pos"] = [rnd.randint(0, 99), 0]; p["nodes"] = [n]
if rnd.random() < .4 and len(ids) > 1:
a, b = rnd.sample(ids, 2); lid = cur["last_link_id"] + 1
p["links"] = {"upsert": [[lid, a, 0, b, rnd.randint(0, 1), "X"]], "added": [lid]}
p["set"] = {"last_link_id": lid}
if rnd.random() < .2 and len(ids) > 3:
p["removed"] = [rnd.choice(ids)]
patches.append(p)
cur = apply_patch(cur, p)
cases.append({"doc": doc, "patches": patches, "expected": cur})
data = tmp_path / "cases.json"
data.write_text(json.dumps(cases))
module = (Path(ROOT) / "web" / "rigshare" / "graphdoc.js").as_uri()
script = tmp_path / "check.mjs"
script.write_text(
f'import fs from "fs"; import {{ applyPatch }} from "{module}";\n'
'const cases = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); let bad = 0;\n'
'for (const c of cases) { let cur = c.doc; for (const p of c.patches) cur = applyPatch(cur, p);\n'
' if (JSON.stringify(cur) !== JSON.stringify(c.expected)) bad++; }\n'
'console.log(bad); process.exit(bad ? 1 : 0);\n')
result = subprocess.run(["node", str(script), str(data)], capture_output=True, text=True)
assert result.returncode == 0, f"{result.stdout} mismatching cases\n{result.stderr}"
+60
View File
@@ -0,0 +1,60 @@
"""HS256 tokens: round trip, tampering, expiry, issuer, algorithm confusion."""
import base64
import json
import time
import pytest
from rigshare_core import jwt
SECRET = b"s3cret"
def claims(**extra):
return {"iss": "rig", "exp": time.time() + 60, **extra}
def b64(obj):
return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode()
def test_round_trip():
token = jwt.encode(claims(sub="bob"), SECRET)
assert jwt.decode(token, SECRET, "rig")["sub"] == "bob"
def test_wrong_secret_and_tampered_payload():
token = jwt.encode(claims(sub="bob"), SECRET)
with pytest.raises(jwt.InvalidToken):
jwt.decode(token, b"other", "rig")
head, _, sig = token.split(".")
forged = f"{head}.{b64(claims(sub='admin'))}.{sig}"
with pytest.raises(jwt.InvalidToken):
jwt.decode(forged, SECRET, "rig")
def test_expiry_leeway_and_not_before():
with pytest.raises(jwt.InvalidToken):
jwt.decode(jwt.encode({"iss": "rig", "exp": time.time() - 3600}, SECRET), SECRET, "rig")
jwt.decode(jwt.encode({"iss": "rig", "exp": time.time() - 5}, SECRET), SECRET, "rig") # inside leeway
assert jwt.decode(jwt.encode({"iss": "rig", "exp": time.time() - 3600}, SECRET), SECRET, "rig", verify_exp=False)
with pytest.raises(jwt.InvalidToken):
jwt.decode(jwt.encode(claims(nbf=time.time() + 3600), SECRET), SECRET, "rig")
def test_wrong_issuer():
with pytest.raises(jwt.InvalidToken):
jwt.decode(jwt.encode(claims(), SECRET), SECRET, "someone-else")
def test_alg_none_is_refused():
token = f"{b64({'alg': 'none', 'typ': 'JWT'})}.{b64(claims())}."
with pytest.raises(jwt.InvalidToken):
jwt.decode(token, SECRET, "rig")
@pytest.mark.parametrize("junk", ["", "a.b", "a.b.c.d", "!!!.???.***", "e30.e30.e30"])
def test_malformed_tokens(junk):
with pytest.raises(jwt.InvalidToken):
jwt.decode(junk, SECRET, "rig")
+707
View File
@@ -0,0 +1,707 @@
"""End-to-end: HTTP API, login gate, websocket hub, folders over ComfyUI's API."""
import asyncio
import json
import os
from urllib.parse import quote
from helpers import PASSWORD, enc, rig_server
BASE_DOC = {"last_node_id": 2, "last_link_id": 0, "nodes": [
{"id": 1, "type": "A", "inputs": [], "outputs": [{"name": "o", "links": []}]},
{"id": 2, "type": "B", "inputs": [{"name": "i", "link": None}, {"name": "j", "link": None}], "outputs": []}],
"links": [], "groups": [], "extra": {}, "version": 0.4}
def test_login_gate_and_setup(run):
async def scenario():
async with rig_server() as rig:
h = rig.http
r = await h.get(rig.url("/"), headers={"Accept": "text/html"}, allow_redirects=False)
assert r.status == 302 and "/rigshare/login" in r.headers["Location"]
assert (await h.get(rig.url("/api/prompt"))).status == 401
status = await (await h.get(rig.url("/rigshare/api/status"))).json()
assert status["needs_setup"]
page = await (await h.get(rig.url("/rigshare/login"))).text()
assert "const SETUP = true" in page
await rig.setup_admin()
r = await h.post(rig.url("/rigshare/api/setup"), json={"username": "evil", "password": PASSWORD})
assert r.status == 409, "setup works only once"
r = await h.post(rig.url("/rigshare/api/login"), json={"username": "boss", "password": "nope-nope"})
assert r.status == 401
run(scenario())
def test_cookie_session_and_logout(run):
async def scenario():
async with rig_server() as rig:
await rig.setup_admin()
r = await rig.http.post(rig.url("/rigshare/api/login"), json={"username": "boss", "password": PASSWORD})
token = (await r.json())["token"]
assert "rigshare_session" in r.cookies
assert (await rig.http.get(rig.url("/api/prompt"))).status == 200, "cookie authenticates"
await rig.http.post(rig.url("/rigshare/api/logout"), headers={"Authorization": f"Bearer {token}"})
assert (await rig.http.get(rig.url("/api/prompt"), headers={"Authorization": f"Bearer {token}"})).status == 401
run(scenario())
def test_api_keys_over_http(run):
async def scenario():
async with rig_server() as rig:
admin = await rig.setup_admin()
r = await rig.http.post(rig.url("/rigshare/api/me/keys"), json={"name": "script"}, headers=admin)
body = await r.json()
key = {"Authorization": f"Bearer {body['key']}"}
assert (await rig.http.get(rig.url("/api/prompt"), headers=key)).status == 200
await rig.http.delete(rig.url(f"/rigshare/api/me/keys/{body['id']}"), headers=admin)
assert (await rig.http.get(rig.url("/api/prompt"), headers=key)).status == 401
run(scenario())
def test_permissions(run):
async def scenario():
async with rig_server() as rig:
admin = await rig.setup_admin()
viewer = await rig.add_user("viewer")
queuer = await rig.add_user("queuer", queue=True)
h = rig.http
assert (await h.post(rig.url("/api/prompt"), headers=viewer)).status == 403
assert (await h.post(rig.url("/api/prompt"), headers=queuer)).status == 200
assert (await h.post(rig.url("/api/manager/queue/install"), headers=queuer)).status == 403
assert (await h.get(rig.url("/customnode/getmappings"), headers=viewer)).status == 200, "read-only lookup"
assert (await h.post(rig.url("/api/manager/queue/install"), headers=admin)).status == 200
await h.patch(rig.url("/rigshare/api/users/queuer"), json={"perms": {"manager": True}}, headers=admin)
assert (await h.post(rig.url("/api/manager/queue/install"), headers=queuer)).status == 200
assert (await h.get(rig.url("/rigshare/api/users"), headers=viewer)).status == 403
run(scenario())
def test_template_filter_and_live_options(run, tmp_path):
templates = tmp_path / "templates"
templates.mkdir()
(templates / "index.json").write_text(json.dumps([
{"title": "Image", "templates": [{"name": "sdxl", "tags": ["Image"], "openSource": True},
{"name": "api_flux", "tags": ["API"], "openSource": False}]},
{"title": "Ads", "templates": [{"name": "x", "tags": ["API"]}]}]))
async def scenario():
async with rig_server(setup=lambda app: app.router.add_static("/templates", str(templates))) as rig:
admin = await rig.setup_admin()
alice = await rig.add_user("alice", edit=True)
sock = await rig.ws("alice")
full = await (await rig.http.get(rig.url("/templates/index.json"), headers=alice)).json()
assert sum(len(c["templates"]) for c in full) == 3
await rig.http.patch(rig.url("/rigshare/api/config"), json={"hide_api_templates": True, "hide_comfy_account": True}, headers=admin)
assert (await sock.until("server"))["server"]["hide_comfy_account"] is True
slim = await (await rig.http.get(rig.url("/templates/index.json"), headers=alice)).json()
assert [t["name"] for c in slim for t in c["templates"]] == ["sdxl"] and len(slim) == 1
await sock.close()
run(scenario())
def test_rooms_conflicts_and_folder_access(run):
async def scenario():
async with rig_server() as rig:
admin = await rig.setup_admin()
for name in ("alice", "bob", "carol"):
await rig.add_user(name, edit=True)
H = rig.headers
r = await rig.http.post(rig.url("/rigshare/api/workspace/mkdir"), json={"parent": "@shared", "name": "Team"}, headers=H["alice"])
assert r.status == 200
alice, bob, carol = [await rig.ws(n) for n in ("alice", "bob", "carol")]
room = "file:workflows/shared/Team/w.json"
await alice.send({"type": "join", "room": room, "name": "w", "doc": BASE_DOC})
joined = await alice.until("room")
assert joined["created"] and joined["manage"] and joined["folder"] == "shared/Team"
await bob.send({"type": "join", "room": room, "name": "w", "doc": BASE_DOC})
assert not (await bob.until("room"))["manage"], "only the folder owner or admins manage access"
await alice.drain(); await bob.drain()
def add(target_slot):
return {"nodes": [{"id": 3, "type": "N", "inputs": [], "outputs": [{"name": "o", "links": [1]}]}], "added": [3],
"links": {"upsert": [[1, 3, 0, 2, target_slot, "X"]], "added": [1]},
"set": {"last_node_id": 3, "last_link_id": 1}}
await alice.send({"type": "graph", "room": room, "patch": add(0)})
await bob.send({"type": "graph", "room": room, "patch": add(1)})
await asyncio.sleep(0.4)
doc = rig.hub.rooms[room].doc
assert sorted(n["id"] for n in doc["nodes"]) == [1, 2, 3, 4], "both nodes kept"
assert [s["link"] for s in doc["nodes"][1]["inputs"]] == [1, 2]
assert any(m["type"] == "doc" and m.get("reason") is None for m in await bob.drain()), "quiet resync"
# Access follows the folder: restricting it takes out whoever lost access.
r = await rig.http.patch(rig.url("/rigshare/api/workspace/folders"), json={"path": "shared/Team", "restricted": True, "members": {"carol": "view"}}, headers=H["bob"])
assert r.status == 403, "only the folder owner or an admin"
r = await rig.http.patch(rig.url("/rigshare/api/workspace/folders"), json={"path": "shared/Team", "restricted": True, "members": {"carol": "view"}}, headers=H["alice"])
assert r.status == 200
msgs = await bob.drain()
assert any(m["type"] == "room_denied" for m in msgs)
presence = [m for m in msgs if m["type"] == "presence"][-1]
assert all(r["key"] != room for r in presence["rooms"])
assert (await alice.until("room_info"))["restricted"] is True
await carol.send({"type": "join", "room": room, "name": "w", "doc": BASE_DOC})
assert (await carol.until("room"))["role"] == "view"
await carol.drain()
await carol.send({"type": "graph", "room": room, "patch": {"nodes": [dict(BASE_DOC["nodes"][0], pos=[9, 9])]}})
assert "view" in ((await carol.until("doc")).get("reason") or "")
await rig.http.patch(rig.url("/rigshare/api/workspace/folders"), json={"path": "shared/Team", "restricted": True, "members": {"carol": "edit"}}, headers=admin)
assert (await carol.until("room_info"))["role"] == "edit"
assert (await rig.http.put(rig.url("/rigshare/api/room/acl"), json={"key": room}, headers=admin)).status in (404, 405), "no per-workflow lists"
for s in (alice, bob, carol):
await s.close()
run(scenario())
def test_folders_over_comfy_api(run):
async def scenario():
async with rig_server() as rig:
await rig.setup_admin()
for name, perms in (("alice", {"edit": True}), ("bob", {"edit": True}), ("carol", {"edit": True}), ("dave", {})):
await rig.add_user(name, **perms)
H, h = rig.headers, rig.http
async def save(user, path):
return (await h.post(rig.url(f"/api/userdata/{enc(path)}"), data=b"{}", headers=H[user])).status
async def listing(user):
r = await h.get(rig.url("/api/userdata?dir=workflows&recurse=true&full_info=true"), headers=H[user])
return {i["path"] for i in await r.json()}
assert await save("alice", "workflows/users/alice/secret.json") == 200
assert await save("dave", "workflows/users/dave/mine.json") == 200
assert await save("dave", "workflows/common.json") == 403
assert await save("bob", "workflows/users/alice/evil.json") == 403
for raw in (enc("workflows/users/alice/secret.json"),
quote(enc("workflows/users/alice/secret.json"), safe=""),
enc("workflows/shared/../users/alice/secret.json"),
"workflows/users/alice/secret.json"):
assert (await h.get(rig.url(f"/api/userdata/{raw}"), headers=H["bob"])).status in (403, 404), raw
assert "users/alice/secret.json" not in await listing("bob")
assert "users/alice/secret.json" in await listing("alice")
r = await h.post(rig.url("/rigshare/api/workspace/mkdir"), json={"parent": "@shared", "name": "Team"}, headers=H["bob"])
assert r.status == 200
assert await save("alice", "workflows/shared/Team/plan.json") == 200
await h.patch(rig.url("/rigshare/api/workspace/folders"), json={"path": "shared/Team", "restricted": True, "members": {"carol": "view"}}, headers=H["bob"])
assert not any(p.startswith("shared/Team/") for p in await listing("alice"))
assert await save("carol", "workflows/shared/Team/plan.json") == 403
assert (await h.get(rig.url(f"/api/userdata/{enc('workflows/shared/Team/plan.json')}"), headers=H["carol"])).status == 200
r = await h.post(rig.url(f"/api/userdata/{enc('workflows/shared/Team/plan.json')}/move/{enc('workflows/users/bob/plan.json')}"), headers=H["bob"])
assert r.status == 200
assert (await h.delete(rig.url(f"/api/userdata/{enc('workflows/users/alice/secret.json')}"), headers=H["bob"])).status == 403
assert (await h.delete(rig.url(f"/api/userdata/{enc('workflows/users/alice/secret.json')}"), headers=H["alice"])).status == 204
sock = await rig.ws("bob")
await sock.send({"type": "join", "room": "file:workflows/users/alice/other.json", "name": "o", "doc": {"nodes": []}})
assert (await sock.until("room_denied"))["room"].endswith("alice/other.json")
await sock.close()
await save("alice", "workflows/users/alice/keep.json")
await h.patch(rig.url("/rigshare/api/users/alice"), json={"username": "alicia"}, headers=H["boss"])
assert os.path.exists(os.path.join(rig.user_root, "workflows", "users", "alicia", "keep.json"))
run(scenario())
def test_live_follows_folders(run):
async def scenario():
async with rig_server() as rig:
await rig.setup_admin()
for name in ("alice", "bob"):
await rig.add_user(name, edit=True)
alice, bob = await rig.ws("alice"), await rig.ws("bob")
# Your own private folder and unsaved tabs never become live.
for key in ("file:workflows/users/alice/mine.json", "tmp:abc"):
await alice.send({"type": "join", "room": key, "name": "x", "doc": BASE_DOC})
assert (await alice.until("room_denied"))["room"] == key
assert not rig.hub.rooms
# Anything else is live while open, and only open ones are listed.
key = "file:workflows/shared/team.json"
await alice.send({"type": "join", "room": key, "name": "team", "doc": BASE_DOC})
await alice.until("room")
presence = [m for m in await bob.drain() if m["type"] == "presence"][-1]
assert [r["key"] for r in presence["rooms"]] == [key]
await alice.send({"type": "join", "room": None})
presence = [m for m in await bob.drain() if m["type"] == "presence"][-1]
assert presence["rooms"] == [], "closed workflows leave the live list"
assert key in rig.hub.rooms, "its state is kept for the next person who opens it"
# Moving it into a private folder takes it out of live sharing.
path = "workflows/shared/team.json"
await rig.http.post(rig.url(f"/api/userdata/{enc(path)}"), data=b"{}", headers=rig.headers["alice"])
await bob.send({"type": "join", "room": key, "name": "team"})
await bob.until("room")
r = await rig.http.post(rig.url(f"/api/userdata/{enc(path)}/move/{enc('workflows/users/alice/team.json')}"), headers=rig.headers["boss"])
assert r.status == 200
assert not rig.hub.rooms
# Deleting a live file closes it for whoever has it open.
key2, path2 = "file:workflows/gone.json", "workflows/gone.json"
await rig.http.post(rig.url(f"/api/userdata/{enc(path2)}"), data=b"{}", headers=rig.headers["alice"])
await bob.send({"type": "join", "room": key2, "name": "gone", "doc": BASE_DOC})
await bob.until("room")
assert (await rig.http.delete(rig.url(f"/api/userdata/{enc(path2)}"), headers=rig.headers["boss"])).status == 204
assert (await bob.until("room_closed"))["room"] == key2
assert key2 not in rig.hub.rooms
await alice.close(); await bob.close()
run(scenario())
def test_moves_follow_open_tabs(run):
async def scenario():
async with rig_server() as rig:
await rig.setup_admin()
for name in ("alice", "bob", "carol", "dave"):
await rig.add_user(name, edit=True)
H, h = rig.headers, rig.http
socks = {n: await rig.ws(n) for n in ("alice", "bob", "carol", "dave")}
async def moves(name):
return [m for m in await socks[name].drain() if m["type"] in ("path_moved", "path_closed")]
# A live file renamed: everyone who could open it is told, and its room follows.
await h.post(rig.url(f"/api/userdata/{enc('workflows/a.json')}"), data=b"{}", headers=H["alice"])
await socks["bob"].send({"type": "join", "room": "file:workflows/a.json", "name": "a", "doc": BASE_DOC})
await socks["bob"].until("room")
for sock in socks.values():
await sock.drain()
r = await h.post(rig.url(f"/api/userdata/{enc('workflows/a.json')}/move/{enc('workflows/b.json')}"), headers=H["alice"])
assert r.status == 200
assert await moves("bob") == [{"type": "path_moved", "from": "workflows/a.json", "to": "workflows/b.json"}]
assert "file:workflows/b.json" in rig.hub.rooms and "file:workflows/a.json" not in rig.hub.rooms
# Into a private folder: the owner follows it, the others keep a private copy.
r = await h.post(rig.url(f"/api/userdata/{enc('workflows/b.json')}/move/{enc('workflows/users/alice/b.json')}"), headers=H["boss"])
assert r.status == 200
assert (await moves("alice"))[0]["type"] == "path_moved"
assert await moves("bob") == [{"type": "path_closed", "from": "workflows/b.json"}]
assert not rig.hub.rooms
# A restricted shared folder renamed: people without access learn nothing.
await h.post(rig.url("/rigshare/api/workspace/mkdir"), json={"parent": "@shared", "name": "Team"}, headers=H["alice"])
await h.patch(rig.url("/rigshare/api/workspace/folders"), json={"path": "shared/Team", "restricted": True, "members": {"carol": "view"}}, headers=H["alice"])
for sock in socks.values():
await sock.drain()
r = await h.patch(rig.url("/rigshare/api/workspace/folders"), json={"path": "shared/Team", "name": "Crew"}, headers=H["alice"])
assert r.status == 200
assert await moves("carol") == [{"type": "path_moved", "from": "workflows/shared/Team", "to": "workflows/shared/Crew"}]
assert await moves("dave") == []
for sock in socks.values():
await sock.close()
run(scenario())
def test_chat_history_pages(run):
async def scenario():
async with rig_server() as rig:
await rig.setup_admin()
sock = await rig.ws("boss")
for i in range(70):
await sock.send({"type": "chat", "text": f"hello {i}"})
await asyncio.sleep(0.3)
await sock.close()
sock = await rig.ws("boss")
await sock.close()
# The welcome carries only the newest page; the rest comes over HTTP.
h = rig.headers["boss"]
session = rig.http
ws = await session.ws_connect(rig.url("/rigshare/ws"), headers=h)
await ws.send_json({"type": "hello"})
while True:
welcome = json.loads((await ws.receive()).data)
if welcome["type"] == "welcome":
break
await ws.close()
assert len(welcome["chat"]) == 50 and welcome["chat_more"]
oldest = welcome["chat"][0]["seq"]
older = await (await session.get(rig.url(f"/rigshare/api/chat?before={oldest}&limit=50"), headers=h)).json()
texts = [m["text"] for m in older["messages"]]
assert not older["more"] and older["messages"][0]["seq"] == 0
seqs = [m["seq"] for m in older["messages"] + welcome["chat"]]
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())
def test_admin_clears_chat_history(run):
async def scenario():
async with rig_server() as rig:
admin = await rig.setup_admin()
alice = await rig.add_user("alice", edit=True)
sock = await rig.ws("alice")
for i in range(5):
await sock.send({"type": "chat", "text": f"hi {i}"})
await asyncio.sleep(0.2)
await sock.drain()
assert (await rig.http.delete(rig.url("/rigshare/api/chat"), headers=alice)).status == 403, "admins only"
assert (await rig.http.delete(rig.url("/rigshare/api/chat"), headers=admin)).status == 200
msgs = await sock.drain()
kinds = [m["type"] for m in msgs]
assert "chat_cleared" in kinds and kinds.index("chat_cleared") < kinds.index("chat")
notice = next(m for m in msgs if m["type"] == "chat")["message"]
assert notice["system"] and "cleared the chat history" in notice["text"] and notice["seq"] == 0
history = await (await rig.http.get(rig.url("/rigshare/api/chat"), headers=alice)).json()
assert [m["text"] for m in history["messages"]] == [notice["text"]] and not history["more"]
await sock.close()
run(scenario())
def test_queue_shows_who_launched_and_guards_cancelling(run):
async def scenario():
async with rig_server() as rig:
admin = await rig.setup_admin()
alice = await rig.add_user("alice", queue=True)
bob = await rig.add_user("bob", queue=True)
h = rig.http
async def queue(user, name):
r = await h.post(rig.url("/api/prompt"), json={"prompt": {"1": {}, "2": {}}},
headers={**user, "X-RigShare-Workflow": quote(name)})
return (await r.json())["prompt_id"]
a = await queue(alice, "Face swap é")
b = await queue(bob, "Upscale")
rig.ps.prompt_queue.running.append(rig.ps.prompt_queue.pending.pop(0)) # alice's starts
from rigshare_core.stats import queue_items
items = rig.hub.with_owners(queue_items(rig.ps))
assert [(i["status"], i["user"], i["workflow"], i["nodes"]) for i in items] == [
("running", "alice", "Face swap é", 2), ("pending", "bob", "Upscale", 2)]
# Your own runs only, unless admin; clearing everyone's is admin-only.
assert (await h.post(rig.url("/api/queue"), json={"delete": [b]}, headers=alice)).status == 403
assert (await h.post(rig.url("/api/interrupt"), json={}, headers=bob)).status == 403, "alice's is running"
assert (await h.post(rig.url("/api/interrupt"), json={"prompt_id": a}, headers=alice)).status == 200
assert (await h.post(rig.url("/api/queue"), json={"clear": True}, headers=bob)).status == 403
assert (await h.post(rig.url("/api/queue"), json={"delete": [b]}, headers=bob)).status == 200
assert not rig.ps.prompt_queue.pending
assert (await h.post(rig.url("/api/queue"), json={"clear": True}, headers=admin)).status == 200
run(scenario())
def test_ended_session_goes_back_to_login(run, tmp_path):
index = tmp_path / "index.html"
index.write_text("<!doctype html><html><head><title>ComfyUI</title></head><body></body></html>")
async def scenario():
async def page(request):
from aiohttp import web
return web.FileResponse(str(index))
async with rig_server(setup=lambda app: app.router.add_get("/", page)) as rig:
admin = await rig.setup_admin()
h = rig.http
r = await h.get(rig.url("/"), headers=admin)
text = await r.text()
assert r.status == 200 and "rigshare-login-watch" in text and text.index("rigshare-login-watch") < text.index("<title>")
assert "no-store" in r.headers.get("Cache-Control", ""), "never served from the browser cache"
# Session gone: API calls say so in a header the page watches for...
await h.post(rig.url("/rigshare/api/logout"), headers=admin)
r = await h.get(rig.url("/api/prompt"), headers={"Authorization": "Bearer nope"})
assert r.status == 401 and r.headers.get("X-RigShare-Login") == "required"
# ...and the page itself redirects to the login screen.
r = await h.get(rig.url("/"), headers={"Accept": "text/html"}, allow_redirects=False)
assert r.status == 302 and r.headers["Location"].startswith("/rigshare/login")
run(scenario())
def test_file_changes_reach_who_can_see_them(run):
async def scenario():
async with rig_server() as rig:
await rig.setup_admin()
alice = await rig.add_user("alice", edit=True)
await rig.add_user("bob", edit=True)
a, b = await rig.ws("alice"), await rig.ws("bob")
h = rig.http
async def changed(sock):
return any(m["type"] == "files_changed" for m in await sock.drain())
await h.post(rig.url(f"/api/userdata/{enc('workflows/users/alice/mine.json')}"), data=b'{"nodes": []}', headers=alice)
assert await changed(a) and not await changed(b), "a private save only tells its owner"
await h.post(rig.url(f"/api/userdata/{enc('workflows/shared/team.json')}"), data=b'{"nodes": []}', headers=alice)
assert await changed(a) and await changed(b)
await h.post(rig.url("/rigshare/api/workspace/mkdir"), json={"parent": "@shared", "name": "Crew"}, headers=alice)
assert await changed(b), "new shared folder"
await h.post(rig.url(f"/api/userdata/{enc('workflows/shared/team.json')}/move/{enc('workflows/shared/Crew/team.json')}"), headers=rig.headers["boss"])
assert await changed(b), "moves"
await a.close(); await b.close()
run(scenario())
def test_other_plugins_server_routes(run):
async def scenario():
async def ok(request):
from aiohttp import web
return web.json_response({"ok": True})
def routes(app):
for path in ("/rgthree/api/config", "/crystools/monitor/switch", "/pixaroma/api/workflows/index",
"/pixaroma/api/workflows/folder", "/pixaroma/api/fonts/list"):
app.router.add_route("*", path, ok)
async with rig_server(setup=routes) as rig:
admin = await rig.setup_admin()
alice = await rig.add_user("alice", edit=True, queue=True)
manager = await rig.add_user("mgr", manager=True)
h = rig.http
async def status(method, path, who):
return (await h.request(method, rig.url(path), headers=who)).status
# Server-wide plugin settings: like ComfyUI Manager.
assert await status("POST", "/rgthree/api/config", alice) == 403
assert await status("POST", "/crystools/monitor/switch", alice) == 403
assert await status("POST", "/rgthree/api/config", manager) == 200
assert await status("GET", "/rgthree/api/config", alice) == 200, "reading is fine"
# Pixaroma's workflow browser reads every folder from disk: admins only.
assert await status("GET", "/pixaroma/api/workflows/index", alice) == 403
assert await status("POST", "/pixaroma/api/workflows/folder", manager) == 403
assert await status("GET", "/pixaroma/api/workflows/index", admin) == 200
assert await status("GET", "/pixaroma/api/fonts/list", alice) == 200, "the rest of Pixaroma works"
run(scenario())
def test_manager_tab_option(run):
async def scenario():
async with rig_server() as rig:
admin = await rig.setup_admin()
sock = await rig.ws("boss")
assert rig.hub.server_info()["manager_tab"] is False, "off by default"
r = await rig.http.patch(rig.url("/rigshare/api/config"), json={"manager_tab": True}, headers=admin)
assert r.status == 200
assert (await sock.until("server"))["server"]["manager_tab"] is True
await sock.close()
run(scenario())
def test_freeing_memory_is_admin_only(run):
async def scenario():
async with rig_server() as rig:
admin = await rig.setup_admin()
queuer = await rig.add_user("queuer", queue=True, manager=True)
body = {"unload_models": True, "free_memory": True}
assert (await rig.http.post(rig.url("/api/free"), json=body, headers=queuer)).status == 403, "affects everyone's runs"
assert (await rig.http.post(rig.url("/api/free"), json=body, headers=admin)).status == 200
run(scenario())
def test_performance_run_log_eta_history_and_clear(run):
async def scenario():
async with rig_server() as rig:
from rigshare_core.stats import queue_items
admin = await rig.setup_admin()
viewer = await rig.add_user("viewer")
hub, h = rig.hub, rig.http
user = {"username": "alice", "display_name": "Alice"}
def run(pid, wf, start, ms, end_event="execution_success"):
hub.record_prompt(pid, user, wf)
hub.run_event("execution_start", {"prompt_id": pid, "timestamp": start})
hub.run_event(end_event, {"prompt_id": pid, "timestamp": start + ms})
run("a", "face", 1_000_000, 20000)
run("b", "face", 2_000_000, 30000)
run("c", "face", 3_000_000, 5000, "execution_interrupted")
run("d", "video", 4_000_000, 90000)
assert [(r["workflow"], r["ms"], r["result"]) for r in rig.store.runs()][-2:] == [("face", 5000, "stopped"), ("video", 90000, "done")]
# The run in progress: its workflow's ETA from the successful runs only.
hub.record_prompt("live", user, "face")
hub.run_event("execution_start", {"prompt_id": "live", "timestamp": int(__import__("time").time() * 1000) - 12000})
rig.ps.prompt_queue.running.append((9, "live", {"1": {}}, {}, []))
perf = hub.performance(hub.with_owners(queue_items(rig.ps)))
live = perf["running"][0]
assert (live["workflow"], live["eta_ms"], live["runs"]) == ("face", 25000, 2) and 11000 <= live["elapsed_ms"] <= 15000
assert perf["etas"] == {"face": {"ms": 25000, "runs": 2}, "video": {"ms": 90000, "runs": 1}}
assert [r["id"] for r in perf["recent"]] == ["d", "c", "b", "a"], "newest first"
# Kept on disk, so it survives a restart.
from rigshare_core.store import Store
assert len(Store(rig.store.folder).runs()) == 4
hist = await (await h.get(rig.url("/rigshare/api/performance/history?workflow=face"), headers=viewer)).json()
assert hist["total"] == 3 and [r["id"] for r in hist["runs"]] == ["c", "b", "a"]
assert (await h.delete(rig.url("/rigshare/api/performance?workflow=face"), headers=viewer)).status == 403
r = await h.delete(rig.url("/rigshare/api/performance?workflow=face"), headers=admin)
assert (await r.json())["cleared"] == 3
assert hub.etas() == {"video": {"ms": 90000, "runs": 1}}, "the ETA starts over"
run(scenario())
def test_folder_routes_refuse_paths_outside_the_workspace(run, tmp_path):
async def scenario():
async with rig_server() as rig:
await rig.setup_admin()
await rig.add_user("alice", edit=True)
outside = tmp_path / "models"
outside.mkdir()
(outside / "keep.bin").write_bytes(b"x")
H, h = rig.headers, rig.http
targets = [str(outside), "C:" + str(outside)[2:], r"..\..\models", "shared/C:/evil"]
for route, extra in (("delete-folder", {}), ("rename-folder", {"name": "x"}), ("move-folder", {"dest": "@shared"})):
for target in targets:
r = await h.post(rig.url(f"/rigshare/api/workspace/{route}"), json={"path": target, **extra}, headers=H["alice"])
assert r.status in (400, 403, 404), (route, target, r.status)
r = await h.post(rig.url("/rigshare/api/workspace/mkdir"), json={"parent": "C:/", "name": "x"}, headers=H["alice"])
assert r.status in (400, 403, 404)
assert (outside / "keep.bin").exists()
run(scenario())
def test_access_is_checked_without_a_live_room(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)
await rig.add_user("viewer")
h = rig.http
r = await h.post(rig.url("/rigshare/api/workspace/mkdir"), json={"parent": "@shared", "name": "Team"}, headers=alice)
assert r.status == 200
rig.hub.workspace.set_acl("shared/Team", {"members": {"alice": "edit"}}, "alice")
path = "workflows/shared/Team/plan.json"
doc = json.dumps({"nodes": [{"id": 1}], "links": []}).encode()
assert (await h.post(rig.url(f"/api/userdata/{enc(path)}"), data=doc, headers=alice)).status == 200
key = "file:" + path
assert key not in rig.hub.rooms, "nobody has it open"
snap_id = rig.store.list_snapshots(key)[0]["id"]
for who_, status in ((alice, 200), (bob, 404)):
r = await h.get(rig.url(f"/rigshare/api/snapshots?room={enc(key)}"), headers=who_)
assert r.status == status, who_
r = await h.get(rig.url(f"/rigshare/api/snapshots/{snap_id}"), headers=who_)
assert r.status == status, who_
# Joining a workflow you cannot open must not create (or seed) a room.
sock = await rig.ws("bob")
await sock.send({"type": "join", "room": key, "name": "plan", "doc": BASE_DOC})
await sock.until("room_denied")
assert key not in rig.hub.rooms
# A viewer cannot be the one who opens a room, so cannot seed it with their own document.
open_key = "file:workflows/common.json"
viewer_sock = await rig.ws("viewer")
await viewer_sock.send({"type": "join", "room": open_key, "name": "common", "doc": BASE_DOC})
assert "editing" in (await viewer_sock.until("room_denied"))["reason"]
assert open_key not in rig.hub.rooms
run(scenario())
+45
View File
@@ -0,0 +1,45 @@
"""Server stats: every metric is optional and never raises."""
from rigshare_core import stats
class Queue:
def __init__(self, running, pending):
self.running, self.pending = running, pending
def get_current_queue_volatile(self):
return self.running, self.pending
class Server:
def __init__(self, queue):
self.prompt_queue = queue
def test_queue_items_order_and_node_count():
server = Server(Queue([(1, "r1", {"1": {}, "2": {}})], [(5, "p5", {"1": {}}), (3, "p3", {})]))
items = stats.queue_items(server)
assert [(i["id"], i["status"], i["nodes"]) for i in items] == [("r1", "running", 2), ("p3", "pending", 0), ("p5", "pending", 1)]
def test_queue_items_survive_odd_entries_and_missing_queue():
assert stats.queue_items(Server(Queue([(1,)], [(9,)]))) == []
assert stats.queue_items(object()) is None
def test_queue_counts_with_fallback():
assert stats._queue(Server(Queue([1], [2, 3]))) == {"running": 1, "pending": 2}
class Old:
def get_tasks_remaining(self):
return 4
assert stats._queue(Server(Old())) == {"running": 0, "pending": 4}
assert stats._queue(object()) is None
def test_collect_stats_without_psutil_or_gpu(monkeypatch):
monkeypatch.setattr(stats, "psutil", None)
monkeypatch.setattr(stats, "pynvml", None)
out = stats.collect_stats(Server(Queue([], [])))
assert out["gpus"] == [] and "cpu" not in out and out["queue"] == {"running": 0, "pending": 0}
assert stats.collect_stats() == {"gpus": []}
+157
View File
@@ -0,0 +1,157 @@
"""Accounts, passwords, JWT sessions, API keys, snapshots."""
import json
import time
import pytest
from helpers import PASSWORD
from rigshare_core import jwt
import rigshare_core.store as store_module
def make_admin(store, name="admin"):
return store.setup_admin(name, PASSWORD)
# ----- setup and accounts ------------------------------------------------
def test_no_default_accounts(store):
assert store.needs_setup
assert store.users == {}
def test_setup_only_once(store):
user = make_admin(store)
assert user["perms"]["admin"] and not store.needs_setup
with pytest.raises(PermissionError):
store.setup_admin("intruder", PASSWORD)
def test_env_provisioning(tmp_path, monkeypatch):
monkeypatch.setenv("RIGSHARE_ADMIN_USER", "ops")
monkeypatch.setenv("RIGSHARE_ADMIN_PASSWORD", PASSWORD)
s = store_module.Store(str(tmp_path))
assert s.users["ops"]["perms"]["admin"]
def test_passwords_are_hashed(store, tmp_path):
make_admin(store)
raw = (tmp_path / "users.json").read_text()
assert PASSWORD not in raw
assert store.users["admin"]["password"].startswith("pbkdf2_sha256$")
def test_authenticate(store):
make_admin(store)
assert store.authenticate("admin", PASSWORD)
assert store.authenticate("admin", "wrong-password") is None
assert store.authenticate("nobody", PASSWORD) is None
@pytest.mark.parametrize("username,password", [("x", PASSWORD), ("bad name", PASSWORD), ("ok_name", "short")])
def test_create_user_validation(store, username, password):
make_admin(store)
with pytest.raises(ValueError):
store.create_user(username, password)
def test_last_admin_is_protected(store):
make_admin(store)
with pytest.raises(ValueError):
store.delete_user("admin")
with pytest.raises(ValueError):
store.update_user("admin", perms={"admin": False})
def test_password_change_revokes_sessions(store):
make_admin(store)
token = store.create_session("admin")
store.update_user("admin", password="another-password")
assert store.user_for_token(token) is None
# ----- JWT sessions --------------------------------------------------------
def claims_of(token):
return json.loads(jwt._b64d(token.split(".")[1]))
def test_session_is_jwt(store):
make_admin(store)
token = store.create_session("admin")
assert token.count(".") == 2
assert store.user_for_token(token)["username"] == "admin"
def test_tampered_token_rejected(store):
make_admin(store)
header, body, sig = store.create_session("admin").split(".")
forged = jwt._b64e(json.dumps({**claims_of(f"{header}.{body}.{sig}"), "sub": "someone"}).encode())
assert store.user_for_token(f"{header}.{forged}.{sig}") is None
assert store.user_for_token(f"{header}.{body}.{sig[:-2]}AA") is None
def test_alg_none_rejected(store):
make_admin(store)
_, body, _ = store.create_session("admin").split(".")
none_header = jwt._b64e(b'{"alg":"none","typ":"JWT"}')
assert store.user_for_token(f"{none_header}.{body}.") is None
def test_expired_token_rejected(store):
make_admin(store)
sid = claims_of(store.create_session("admin"))["sid"]
old = jwt.encode({"iss": "rigshare", "sub": "admin", "sid": sid, "iat": 0, "nbf": 0, "exp": 1}, store.secret)
assert store.user_for_token(old) is None
def test_logout_revokes_immediately(store):
make_admin(store)
token = store.create_session("admin")
store.revoke_session(token)
assert store.user_for_token(token) is None
def test_refresh_only_when_old(store, monkeypatch):
make_admin(store)
token = store.create_session("admin")
assert store.refresh_token(token) is None
monkeypatch.setattr(store_module, "TOKEN_REFRESH", -1)
fresh = store.refresh_token(token)
assert fresh and store.user_for_token(fresh)["username"] == "admin"
assert claims_of(fresh)["sid"] == claims_of(token)["sid"]
def test_rename_keeps_sessions_and_keys(store):
make_admin(store)
token = store.create_session("admin")
key, _ = store.create_api_key("admin", "k")
store.rename_user("admin", "root")
assert store.user_for_token(token)["username"] == "root"
assert store.user_for_token(key)["username"] == "root"
# ----- API keys ---------------------------------------------------------------
def test_api_keys(store, tmp_path):
make_admin(store)
key, meta = store.create_api_key("admin", "script")
assert key.startswith("rs_") and key not in (tmp_path / "users.json").read_text()
assert store.user_for_token(key)["username"] == "admin"
assert "hash" not in store.list_api_keys("admin")[0]
store.revoke_api_key("admin", meta["id"])
assert store.user_for_token(key) is None
# ----- snapshots ----------------------------------------------------------------
def test_snapshot_rotation_keeps_labelled(store):
store.update_config({"snapshot_keep": 2})
for i in range(4):
store.add_snapshot({"nodes": []}, "me", "room", "Room")
time.sleep(0.01)
store.add_snapshot({"nodes": []}, "me", "room", "Room", label="keep me")
snaps = store.list_snapshots("room")
assert len([s for s in snaps if not s["label"]]) == 2
assert any(s["label"] == "keep me" for s in snaps)
+198
View File
@@ -0,0 +1,198 @@
"""Folder rules: path normalisation, roles, deletes, moves, browsing."""
import os
import pytest
from helpers import Store
from rigshare_core.workspace import Workspace, norm
ADMIN = {"edit": True, "queue": True, "admin": True}
EDITOR = {"edit": True, "queue": True, "admin": False}
VIEWER = {"edit": False, "queue": False, "admin": False}
@pytest.fixture
def ws(tmp_path):
root = tmp_path / "workflows"
root.mkdir()
return Workspace(Store(str(tmp_path / "data")), str(root))
def touch(ws, rel):
path = os.path.join(ws.root, *rel.split("/"))
os.makedirs(os.path.dirname(path), exist_ok=True)
open(path, "w").write("{}")
@pytest.mark.parametrize("raw,expected", [
("workflows/a.json", "workflows/a.json"),
("workflows%2Fusers%2Fbob%2Fa.json", "workflows/users/bob/a.json"),
("workflows%252Fusers%252Fbob%252Fa.json", "workflows/users/bob/a.json"),
("workflows/shared/../users/bob/a.json", "workflows/users/bob/a.json"),
("../../etc/passwd", "etc/passwd"),
("workflows\\users\\bob", "workflows/users/bob"),
("", ""),
])
def test_norm(raw, expected):
assert norm(raw) == expected
def test_areas():
assert Workspace.area("users/bob/x.json") == ("private", "bob")
assert Workspace.area("shared/Team/x.json") == ("shared", "shared/Team")
assert Workspace.area("x.json") == ("common", None)
assert Workspace.workflows_rel("workflows/a/b.json") == "a/b.json"
assert Workspace.workflows_rel("subgraphs/x.json") is None
def test_private_space(ws):
assert ws.role("users/bob/x.json", "user", "bob", VIEWER) == "edit", "own space even for viewer accounts"
assert ws.role("users/bob/x.json", "user", "alice", EDITOR) is None
assert ws.role("users/bob/x.json", "user", "root", ADMIN) == "edit"
assert ws.can_delete("users/bob/x.json", "user", "bob", VIEWER)
assert not ws.can_delete("users/bob/x.json", "user", "alice", EDITOR)
def test_common_space(ws):
assert ws.role("x.json", "user", "alice", EDITOR) == "edit"
assert ws.role("x.json", "user", "dave", VIEWER) == "view"
assert not ws.can_delete("x.json", "user", "alice", EDITOR), "Common deletes are admin-only"
assert ws.can_delete("x.json", "user", "root", ADMIN)
def test_shared_folder_access_list(ws):
folder = ws.create_folder("Team", "bob")
assert ws.role(f"{folder}/a.json", "user", "alice", EDITOR) == "edit", "open by default"
ws.set_acl(folder, {"members": {"carol": "view", "erin": "edit"}})
assert ws.role(f"{folder}/a.json", "user", "alice", EDITOR) is None
assert ws.role(f"{folder}/a.json", "user", "carol", EDITOR) == "view"
assert ws.role(f"{folder}/a.json", "user", "erin", EDITOR) == "edit"
assert ws.role(f"{folder}/a.json", "user", "erin", VIEWER) == "view", "a role never exceeds account perms"
assert ws.role(f"{folder}/a.json", "user", "bob", EDITOR) == "edit", "owner always"
assert ws.can_delete(f"{folder}/a.json", "user", "bob", EDITOR)
assert not ws.can_delete(f"{folder}/a.json", "user", "erin", EDITOR)
def test_moves(ws):
folder = ws.create_folder("Team", "bob")
assert ws.can_move("users/bob/a.json", f"{folder}/a.json", "user", "bob", EDITOR)
assert ws.can_move(f"{folder}/a.json", f"{folder}/b.json", "user", "alice", EDITOR), "rename in place"
assert not ws.can_move(f"{folder}/a.json", "users/alice/a.json", "user", "alice", EDITOR), "only owner moves out"
assert not ws.can_move("users/bob/a.json", "users/alice/a.json", "user", "bob", EDITOR)
@pytest.mark.parametrize("name", ["", "..", "a/b", "x" * 80, ".hidden"])
def test_bad_folder_names(ws, name):
with pytest.raises(ValueError):
ws.create_folder(name, "bob")
def test_list_dir(ws):
folder = ws.create_folder("Team", "bob")
touch(ws, "common.json")
touch(ws, "users/alice/secret.json")
touch(ws, f"{folder}/plan.json")
shared = ws.list_dir("@shared", "user", "alice", EDITOR)
assert {e["name"] for e in shared["entries"]} == {"Team", "common"}
ws.set_acl(folder, {"members": {}})
shared = ws.list_dir("@shared", "user", "alice", EDITOR)
assert {e["name"] for e in shared["entries"]} == {"common"}
with pytest.raises(PermissionError):
ws.list_dir("users/alice", "user", "bob", EDITOR)
with pytest.raises(PermissionError):
ws.list_dir("users", "user", "bob", EDITOR)
mine = ws.list_dir("users/alice", "user", "alice", EDITOR)
assert [e["name"] for e in mine["entries"]] == ["secret"] and mine["can_mkdir"]
def test_folder_operations(ws):
touch(ws, "users/alice/keep.json")
sub = ws.make_dir("users/alice", "renders", "user", "alice", EDITOR)
assert sub == "users/alice/renders"
with pytest.raises(PermissionError):
ws.make_dir("users/alice", "x", "user", "bob", EDITOR)
with pytest.raises(PermissionError):
ws.rename_dir("users/alice", "hack", "user", "alice", EDITOR)
assert ws.rename_dir(sub, "finals", "user", "alice", EDITOR) == "users/alice/finals"
team = ws.make_dir("@shared", "Team", "user", "bob", EDITOR)
assert team == "shared/Team" and ws.folders[team]["owner"] == "bob"
assert ws.move_dir("users/alice/finals", team, "user", "alice", EDITOR) == "shared/Team/finals"
with pytest.raises(PermissionError):
ws.delete_dir(team, "user", "alice", EDITOR)
ws.delete_dir("shared/Team/finals", "user", "bob", EDITOR)
with pytest.raises(PermissionError):
ws.delete_dir("users/alice", "user", "alice", EDITOR)
def test_rename_user_moves_private_folder(ws):
touch(ws, "users/alice/keep.json")
ws.create_folder("Team", "alice")
ws.rename_user("alice", "alicia")
assert os.path.exists(os.path.join(ws.root, "users", "alicia", "keep.json"))
assert ws.folders["shared/Team"]["owner"] == "alicia"
@pytest.mark.parametrize("raw", ["C:/models", "C:", "a/D:x/b", "c%3A%5Cmodels"])
def test_norm_refuses_drive_segments(raw):
with pytest.raises(PermissionError):
norm(raw)
def test_norm_still_resolves_dotdot_inside_the_tree():
assert norm(r"..\..\x") == "x"
assert norm("a/../b") == "b"
@pytest.mark.parametrize("bad", ["C:/models", "C:", "..", "a/../..", "x:y"])
def test_safe_join_refuses_escapes(ws, bad):
from rigshare_core.workspace import safe_join
with pytest.raises(PermissionError):
safe_join(ws.root, bad)
def test_folder_ops_cannot_leave_the_workspace(ws, tmp_path):
outside = tmp_path / "models"
outside.mkdir()
(outside / "keep.txt").write_text("x")
# A colon segment (drive prefix) is refused outright; a plain absolute path is only ever
# read relative to the workflows root (a leading slash is dropped), so it can't reach `outside`.
for bad in ("C:" + str(outside)[2:], "shared/C:evil"):
with pytest.raises(PermissionError):
ws.delete_folder(bad)
with pytest.raises(PermissionError):
ws.rename_folder(bad, "renamed")
for plain in (str(outside), str(outside).replace("\\", "/")):
try:
ws.delete_folder(plain)
except (PermissionError, ValueError):
pass
assert (outside / "keep.txt").exists()
@pytest.mark.skipif(os.name != "nt", reason="trailing dots, spaces and short names only alias on Windows")
@pytest.mark.parametrize("raw", ["users./bob/a.json", "users /bob", "USERS~1/bob", "users/bob./a.json"])
def test_norm_refuses_windows_aliases(raw):
with pytest.raises(PermissionError):
norm(raw)
@pytest.mark.skipif(os.name != "nt", reason="case-insensitive filesystem")
def test_case_variants_cannot_bypass_private_folders(ws):
touch(ws, "users/alice/a.json")
assert norm("Users/Alice/a.json") == "users/alice/a.json"
assert norm("Workflows/USERS/ALICE") == "workflows/users/alice"
assert ws.role(norm("USERS/alice/a.json"), "user", "bob", EDITOR) is None
def test_name_rules():
from rigshare_core.workspace import FOLDER_NAME_RE
from rigshare_core.store import USERNAME_RE
for bad in ("con", "NUL", "com1.txt", "art.", "art ", "bad\n"):
assert not FOLDER_NAME_RE.match(bad), bad
for bad in ("..", "bob.", ".bob", "con", "aux", "ab\n", "a"):
assert not USERNAME_RE.match(bad), bad
for ok in ("Art (v2)", "a", "my-folder"):
assert FOLDER_NAME_RE.match(ok), ok
for ok in ("bob", "al.ice", "x_y-z", "ab"):
assert USERNAME_RE.match(ok), ok
+149
View File
@@ -0,0 +1,149 @@
// RigShare in ComfyUI's App mode.
//
// The App view's sidebar only draws buttons for a fixed set of tabs (assets
// and apps), but its docked side panel shows whichever sidebar tab is active.
// So RigShare adds its own buttons to that sidebar, copied from ComfyUI's so
// they look the same, and they open the regular RigShare and Files tabs in the
// docked panel, exactly like ComfyUI's own tabs. If the sidebar can't be
// found, a small floating dock offers the same buttons.
import { h } from "./ui.js";
const RAIL = 'nav[data-testid="side-toolbar"]';
export class AppDock {
/**
* views: { id: { icon, title, tabId } }, tabId being a registered sidebar tab.
*/
constructor({ app, sync, views }) {
this.app = app;
this.sync = sync;
this.views = views;
this.badges = {};
this.railButtons = {};
this.dockButtons = {};
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";
document.body.append(this.dock);
this.wasApp = false;
this.graphTab = null; // our tab open in the graph view, if any
this.restore = null; // { tabId, until }: reopen it after entering the App view
setInterval(() => this.update(), 300);
}
get activeTabId() {
return this.app.extensionManager?.sidebarTab?.activeSidebarTabId ?? null;
}
/** Open (or close) the tab in ComfyUI's docked side panel, like its own buttons do. */
toggle(id) {
this.app.extensionManager?.command?.execute(`Workspace.ToggleSidebarTab.${this.views[id].tabId}`);
setTimeout(() => this.refreshButtons(), 30);
}
update() {
const active = this.sync.inAppMode;
this.keepOpenIntoApp(active);
const rail = active ? document.querySelector(RAIL) : null;
if (rail) this.addToRail(rail);
else this.removeFromRail();
this.dock.style.display = active && !rail ? "" : "none";
this.refreshButtons();
}
/**
* ComfyUI closes the side panel when a tab enters the App view (not when it
* goes back to the graph). If a RigShare tab was open, open it again.
*/
keepOpenIntoApp(inApp) {
const open = this.activeTabId;
const ours = Object.values(this.views).map((v) => v.tabId);
if (!inApp) {
this.graphTab = ours.includes(open) ? open : null;
this.restore = null;
} else if (!this.wasApp && this.graphTab) {
this.restore = { tabId: this.graphTab, until: Date.now() + 1500 };
}
this.wasApp = inApp;
if (!this.restore) return;
if (open === this.restore.tabId || Date.now() > this.restore.until) {
this.restore = null;
} else if (open === null) {
this.app.extensionManager?.command?.execute(`Workspace.ToggleSidebarTab.${this.restore.tabId}`);
}
}
/** 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 = {};
}
refreshButtons() {
const open = this.activeTabId;
for (const [id, b] of Object.entries(this.railButtons)) {
b.btn.classList.toggle("side-bar-button-selected", this.views[id].tabId === open);
this.paintBadge(b.badge, this.badges[id]);
}
for (const [id, b] of Object.entries(this.dockButtons)) {
b.btn.classList.toggle("active", this.views[id].tabId === 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();
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 118 KiB

+20 -2
View File
@@ -28,7 +28,8 @@ export class RigClient extends EventTarget {
this.status = "offline"; // offline | connecting | online | denied
this.self = null;
this.users = [];
this.chat = [];
this.chat = []; // loaded chat messages, oldest first
this.chatMore = false; // older messages exist on the server
this.rooms = [];
this.stats = null;
this.server = {};
@@ -105,6 +106,7 @@ export class RigClient extends EventTarget {
this.self = msg.self;
this.users = msg.users;
this.chat = msg.chat || [];
this.chatMore = !!msg.chat_more;
this.rooms = msg.rooms || [];
this.stats = msg.stats;
this.server = msg.server || {};
@@ -144,9 +146,13 @@ export class RigClient extends EventTarget {
this.stats = msg.stats;
this.emit("stats", msg.stats);
break;
case "chat_cleared":
this.chat = [];
this.chatMore = false;
this.emit("chat-history", this.chat);
break;
case "chat":
this.chat.push(msg.message);
if (this.chat.length > 500) this.chat.shift();
this.emit("chat", msg.message);
break;
default:
@@ -200,6 +206,18 @@ export class RigClient extends EventTarget {
return { "X-RigShare-Guest": this.guestId };
}
/** Fetch the page of chat before the oldest loaded message; returns them (oldest first). */
async loadOlderChat(limit = 50) {
const oldest = this.chat[0]?.seq;
if (!this.chatMore || oldest === undefined) return [];
const data = await this.request("GET", `/rigshare/api/chat?before=${oldest}&limit=${limit}`);
// A reconnect may have replaced the history meanwhile.
if (this.chat[0]?.seq !== oldest) return [];
this.chat.unshift(...data.messages);
this.chatMore = data.more;
return data.messages;
}
async request(method, path, body) {
const base = location.pathname.replace(/[^/]*$/, "");
const res = await fetch(base + path.replace(/^\//, ""), {
+419
View File
@@ -0,0 +1,419 @@
// Drive-style file browser for the RigShare workspace.
//
// [My files] users/<you>/… only you (and admins)
// [Shared] shared folders + the common top level
// [All] the whole workflows folder, everyone's files (admins)
//
// 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, APP_ICON } from "./ui.js";
const SHARED = "@shared";
const ALL = "";
export class FilesBrowser {
constructor({ app, client, sync, mode = "browse", start, onOpenFile, onPickFile, onChange, accessEditor }) {
Object.assign(this, { app, client, sync, mode, onOpenFile, onPickFile, onChange, accessEditor });
this.path = null;
this.tab = null;
this.listing = null;
this.filter = "";
this.accessFor = null;
this.crumbs = h("div", { class: "rs-fb-crumbs" });
this.search = h("input", {
class: "rs-input rs-fb-search", placeholder: "Filter", type: "search",
oninput: () => { this.filter = this.search.value.trim().toLowerCase(); this.renderList(); },
onkeydown: (e) => e.stopPropagation(),
});
this.actions = h("div", { class: "rs-fb-actions" });
this.tabsEl = h("div", { class: "rs-fb-tabs", role: "tablist" });
this.list = h("div", { class: "rs-fb-list" });
this.el = h("div", { class: `rs-fb rs-fb-${mode}` },
this.tabsEl,
h("div", { class: "rs-fb-bar" }, this.crumbs, this.actions),
h("div", { class: "rs-fb-tools" }, this.search),
this.list);
// The sidebar browser lives as long as the page: keep its live dots current.
if (mode === "browse") {
client.on("presence", () => { if (this.listing && this.el.isConnected) this.renderList(); });
// Someone (maybe you, from the Save dialog) saved, moved or deleted a workflow: reload.
client.on("files_changed", () => {
clearTimeout(this.changedTimer);
this.changedTimer = setTimeout(() => { if (this.el.isConnected && !this.accessFor) this.reload(); }, 250);
});
}
this.setStart(start);
}
get me() {
return this.client.self?.key;
}
/** The folder tabs: each is a root the browser can't go above. */
get tabs() {
return [
this.me ? { id: "mine", label: "My files", icon: "pi-user", root: `users/${this.me}`, hint: "Only you" } : null,
{ id: "shared", label: "Shared", icon: "pi-users", root: SHARED, hint: "Shared folders and common workflows" },
this.client.perms.admin ? { id: "all", label: "All", icon: "pi-shield", root: ALL, hint: "The whole workflows folder, everyone's files (admins)" } : null,
].filter(Boolean);
}
/** Which tab a path belongs to when opened directly (not through a tab). */
tabFor(path) {
const ids = this.tabs.map((t) => t.id);
if (this.me && (path === `users/${this.me}` || path?.startsWith(`users/${this.me}/`))) return "mine";
if (path === ALL || path === "users" || path?.startsWith("users/")) return ids.includes("all") ? "all" : ids[0];
return "shared";
}
setStart(start) {
if (start === undefined || start === null || start === "@home") start = this.me ? `users/${this.me}` : SHARED;
this.tab = this.tabFor(start);
const allowed = this.tab === "all" || this.tab === "shared" || start.startsWith(`users/${this.me}`);
this.path = allowed && !(this.tab === "shared" && (start === ALL || start.startsWith("users"))) ? start : this.currentTab.root;
}
get currentTab() {
return this.tabs.find((t) => t.id === this.tab) ?? this.tabs[0];
}
async switchTab(id) {
const tab = this.tabs.find((t) => t.id === id);
if (!tab) return;
this.tab = id;
await this.go(tab.root);
}
/** Folder path usable for saving ("" is the common top level), or null. */
get saveFolder() {
if (!this.listing || this.listing.role !== "edit") return null;
if (this.path === "users") return null;
return this.path === SHARED ? "" : this.path;
}
toast(severity, detail) {
this.client.emit("toast", { severity, summary: "RigShare", detail });
}
async run(fn, ok) {
try {
const r = await fn();
if (ok) this.toast("success", ok);
return r ?? true;
} catch (e) {
this.toast("error", e.message || String(e));
return undefined;
}
}
async go(path) {
this.path = path;
this.filter = "";
this.search.value = "";
this.accessFor = null;
await this.reload();
}
async reload() {
// Tabs depend on who is signed in (admin, username): keep the current one valid.
if (!this.tabs.some((t) => t.id === this.tab)) {
this.tab = this.tabs[0].id;
this.path = this.tabs[0].root;
}
this.list.replaceChildren(h("div", { class: "rs-fb-empty" }, h("i", { class: "pi pi-spin pi-spinner" })));
try {
this.listing = await this.client.request("GET", `/rigshare/api/workspace/list?path=${encodeURIComponent(this.path)}`);
} catch (e) {
this.toast("error", e.message);
this.listing = null;
const root = this.currentTab.root;
if (this.path !== root) return this.go(root);
}
this.render();
this.onChange?.(this);
}
// ----- breadcrumbs ------------------------------------------------------
/** Breadcrumbs from the current tab's root down to the current folder. */
trail() {
const tab = this.currentTab;
const out = [{ label: tab.label, path: tab.root }];
const p = this.path;
if (p === tab.root) return out;
const parts = p.split("/");
// Number of leading path parts the tab root stands for.
let skip = 0;
if (tab.id === "mine") skip = 2;
else if (tab.id === "shared" && parts[0] === "shared") skip = 1;
parts.slice(skip).forEach((part, i) => out.push({ label: part, path: parts.slice(0, i + 1 + skip).join("/") }));
return out;
}
/** Human label of the current folder, e.g. "My files / renders". */
get label() {
return this.trail().map((c) => c.label).join(" / ");
}
render() {
this.tabsEl.replaceChildren(...this.tabs.map((t) => h("button", {
class: `rs-fb-tab ${t.id === this.tab ? "active" : ""}`, role: "tab", title: t.hint,
"aria-selected": String(t.id === this.tab),
onclick: () => { if (t.id !== this.tab || this.path !== t.root) this.switchTab(t.id); },
}, h("i", { class: `pi ${t.icon}` }), h("span", {}, t.label))));
const trail = this.trail();
this.crumbs.replaceChildren(...trail.flatMap((c, i) => [
i ? h("i", { class: "pi pi-angle-right rs-fb-sep" }) : null,
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(),
}, h("i", { class: "pi pi-folder-plus" })) : null,
h("button", { class: "rs-btn rs-btn-icon", title: "Refresh", onclick: () => this.reload() }, h("i", { class: "pi pi-refresh" })),
].filter(Boolean));
this.renderList();
}
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."));
return;
}
const rows = [];
for (const e of entries) {
rows.push(this.row(e));
if (this.accessFor === e.path && this.accessEditor) rows.push(this.accessEditor(e.path, () => { this.accessFor = null; this.reload(); }));
}
this.list.replaceChildren(...rows);
}
row(e) {
const isFolder = e.type === "folder";
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(" · ")
: [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;
const tools = this.mode === "browse" ? this.rowTools(e) : [];
return h("div", { class: `rs-fb-row ${isFolder ? "folder" : "file"} ${disabled ? "disabled" : ""}` },
h("button", {
class: "rs-fb-main", disabled,
ondblclick: () => { if (!isFolder && this.mode === "save") this.onOpenFile?.(e.path); },
onclick: () => {
if (isFolder) return this.go(e.path);
if (this.mode === "save") return this.onPickFile?.(e);
if (this.mode !== "pick") this.onOpenFile?.(`workflows/${e.path}`);
},
},
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),
meta ? h("div", { class: "rs-muted rs-small rs-ellipsis" }, meta) : null)),
tools.length ? h("div", { class: "rs-fb-tools-row" }, ...tools) : null);
}
rowTools(e) {
const isFolder = e.type === "folder";
const topShared = isFolder && e.manage !== undefined;
const tools = [];
if (topShared && e.manage) {
tools.push(h("button", { class: "rs-btn rs-btn-icon rs-btn-sm", title: "Who can open it", onclick: () => {
this.accessFor = this.accessFor === e.path ? null : e.path; this.renderList();
} }, h("i", { class: "pi pi-lock" })));
}
const canRename = isFolder ? (topShared ? e.manage : e.deletable) : e.role === "edit";
if (canRename) tools.push(h("button", { class: "rs-btn rs-btn-icon rs-btn-sm", title: "Rename", onclick: () => this.rename(e) }, h("i", { class: "pi pi-pencil" })));
if (e.deletable && !topShared) tools.push(h("button", { class: "rs-btn rs-btn-icon rs-btn-sm", title: "Move to…", onclick: () => this.move(e) }, h("i", { class: "pi pi-arrow-right-arrow-left" })));
if (e.deletable) tools.push(h("button", { class: "rs-btn rs-btn-icon rs-btn-sm rs-danger", title: "Delete", onclick: () => this.remove(e) }, h("i", { class: "pi pi-trash" })));
return tools;
}
// ----- 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);
}
async confirm(title, message) {
const dialog = this.app.extensionManager?.dialog;
if (dialog?.confirm) return !!(await dialog.confirm({ title, message }));
return window.confirm(message);
}
async refreshComfy() {
await this.app.extensionManager?.workflow?.syncWorkflows?.();
}
async newFolder() {
const name = await this.ask(this.path === SHARED ? "New shared folder" : "New folder", "Folder name");
if (!name) return;
const res = await this.run(() => this.client.request("POST", "/rigshare/api/workspace/mkdir", { parent: this.path, name }));
if (res) this.reload();
}
async rename(e) {
const name = await this.ask("Rename", `New name for "${e.name}"`, e.name);
if (!name || name === e.name) return;
if (e.type === "folder") {
await this.run(() => this.client.request("POST", "/rigshare/api/workspace/rename-folder", { path: e.path, name }), "Renamed");
await this.refreshComfy();
} else {
const dir = e.path.includes("/") ? e.path.slice(0, e.path.lastIndexOf("/") + 1) : "";
const clean = name.replace(/\.json$/i, "").replace(/[\\/]+/g, "-");
await this.run(() => this.sync.movePath(`workflows/${e.path}`, `workflows/${dir}${clean}.json`), "Renamed");
}
this.reload();
}
async move(e) {
const dest = await pickFolder({ app: this.app, client: this.client, sync: this.sync, title: `Move "${e.name}" to…`, start: this.path });
if (dest === null || dest === undefined) return;
if (e.type === "folder") {
await this.run(() => this.client.request("POST", "/rigshare/api/workspace/move-folder", { path: e.path, dest }), "Moved");
await this.refreshComfy();
} else {
const base = e.path.split("/").pop();
await this.run(() => this.sync.movePath(`workflows/${e.path}`, dest ? `workflows/${dest}/${base}` : `workflows/${base}`), "Moved");
}
this.reload();
}
async remove(e) {
const what = e.type === "folder" ? `the folder "${e.name}"` : `"${e.name}"`;
if (!(await this.confirm("Delete", `Delete ${what}? This cannot be undone.`))) return;
// Its open tabs close first (ComfyUI would otherwise keep them as orphans).
const path = `workflows/${e.path}`;
const closing = this.sync.tabsUnder(path).map((wf) => `file:${wf.path}`);
await this.sync.closeTabsUnder(path);
let done;
if (e.type === "folder") {
done = await this.run(() => this.client.request("POST", "/rigshare/api/workspace/delete-folder", { path: e.path }), "Deleted");
} else {
done = await this.run(async () => {
const store = this.app.extensionManager.workflow;
await store.syncWorkflows?.();
const wf = store.getWorkflowByPath?.(`workflows/${e.path}`);
if (wf && store.deleteWorkflow) await store.deleteWorkflow(wf);
else {
const { api } = await import("../../../scripts/api.js");
const res = await api.deleteUserData(`workflows/${e.path}`);
if (res.status !== 204) throw new Error(`Could not delete (${res.status})`);
}
}, "Deleted");
}
if (!done) for (const key of closing) this.sync.denied.delete(key); // still there: reopening it rejoins
await this.refreshComfy();
this.reload();
}
}
// ----- modal helpers ----------------------------------------------------------
export function modal(title, icon, body, footer) {
let resolveClose;
const closed = new Promise((r) => { resolveClose = r; });
const close = (value) => {
backdrop.remove();
document.removeEventListener("keydown", onKey, true);
resolveClose(value);
};
// 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),
h("button", { class: "rs-btn rs-btn-icon rs-btn-ghost", title: "Close", onclick: () => close(null) }, h("i", { class: "pi pi-times" }))),
body,
footer ? h("div", { class: "rs-modal-footer" }, footer) : null));
backdrop.addEventListener("keydown", (e) => e.stopPropagation());
document.body.append(backdrop);
document.addEventListener("keydown", onKey, true);
return { close, closed };
}
/** A yes/no question in RigShare's own modal. */
export async function confirmModal(title, message, confirmLabel = "OK", danger = false) {
const ok = h("button", { class: `rs-btn ${danger ? "rs-btn-danger" : "rs-btn-primary"}` }, confirmLabel);
const m = modal(title, "pi-question-circle", h("p", { class: "rs-confirm-text" }, message),
[h("span", { class: "rs-grow" }), h("button", { class: "rs-btn rs-btn-ghost", onclick: () => m.close(false) }, "Cancel"), ok]);
ok.onclick = () => m.close(true);
setTimeout(() => ok.focus(), 30);
return !!(await m.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 });
const choose = h("button", { class: "rs-btn rs-btn-primary" }, "Move here");
const where = h("span", { class: "rs-muted rs-small rs-grow rs-ellipsis" });
browser.onChange = () => {
choose.disabled = browser.saveFolder === null;
where.textContent = browser.saveFolder === null ? "Open a folder you can edit" : browser.label;
};
const m = modal(title, "pi-arrow-right-arrow-left", browser.el, [where, choose]);
choose.onclick = () => m.close(browser.saveFolder);
await browser.reload();
return m.closed;
}
/** "Open from RigShare": browse and open a workflow. */
export async function openFromWorkspace({ app, client, sync }) {
const browser = new FilesBrowser({
app, client, sync, mode: "open", start: storage.get("rigshare.lastOpenFolder") ?? undefined,
onOpenFile: async (path) => {
storage.set("rigshare.lastOpenFolder", browser.path);
m.close(true);
try { await sync.openPath(path); } catch (e) { client.emit("toast", { severity: "error", summary: "RigShare", detail: e.message }); }
},
});
const m = modal("Open from RigShare", "pi-folder-open", browser.el, null);
await browser.reload();
return m.closed;
}
+177
View File
@@ -0,0 +1,177 @@
// Floating cards over the canvas (the server monitor, performance): draggable,
// and with a magnet that docks them in the bottom-right corner, side by side
// from right to left in the order they were created: beside the minimap and zoom
// toolbar in the graph, in the corner of the App view. Dragging undocks a card.
import { h, storage } from "./ui.js";
const GAP = 10;
// Lucide "magnet" (ISC), inline: neither PrimeIcons nor ComfyUI ships one.
const MAGNET_SVG = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round"><path d="m6 15-4-4 6.75-6.77a7.79 7.79 0 0 1 11 11L13 22l-4-4 6.39-6.36a2.14 2.14 0 0 0-3-3L6 15"/>
<path d="m5 8 4 4"/><path d="m12 15 4 4"/></svg>`;
/** Visible element's box, or null. */
function box(selector) {
const el = document.querySelector(selector);
if (!el) return null;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0 ? r : null;
}
/** ComfyUI's zoom toolbar: the minimap toggle's first ancestor shaped like the bar. */
function toolbarBox() {
let el = document.querySelector('[data-testid="toggle-minimap-button"]');
while (el && el !== document.body) {
const r = el.getBoundingClientRect();
if (r.width >= 120 && r.height > 0 && r.height < 120) return r;
el = el.parentElement;
}
return null;
}
const cards = []; // every card, bottom of the stack first
export class FloatCard {
/**
* key: storage prefix; title/icon: the header; inAppMode(): App view on screen.
* Subclasses fill this.body (and this.actions) in render().
*/
constructor({ key, title, icon, inAppMode, onChange, className = "" }) {
this.key = key;
this.className = className;
this.title = title;
this.icon = icon;
this.inAppMode = inAppMode ?? (() => false);
this.onChange = onChange;
this.enabled = storage.get(`${key}`) === "1";
this.magnet = storage.get(`${key}Magnet`) !== "0"; // docked unless the user moved it
this.el = null;
cards.push(this);
window.addEventListener("resize", () => this.dock());
setInterval(() => this.dock(), 500); // panels open and close, the minimap toggles...
}
start() {
if (this.enabled) this.show();
}
toggle() {
this.enabled = !this.enabled;
storage.set(this.key, this.enabled ? "1" : null);
this.enabled ? this.show() : this.hide();
this.onChange?.();
for (const c of cards) c.dock(); // the stack closes up or opens
}
show() {
if (this.el) return;
this.body = h("div", { class: "rs-float-body" });
this.actions = h("div", { class: "rs-float-actions" });
this.actions.style.display = "none";
this.magnetBtn = h("button", { class: "rs-float-close rs-float-magnet", onclick: () => this.setMagnet(!this.magnet) });
this.magnetBtn.innerHTML = MAGNET_SVG;
const head = h("div", { class: "rs-float-head" },
h("i", { class: `pi ${this.icon}` }), h("span", { class: "rs-grow" }, this.title),
...this.headButtons(),
this.magnetBtn,
h("button", { class: "rs-float-close", title: "Close", onclick: () => this.toggle() }, h("i", { class: "pi pi-times" })));
this.el = h("div", { class: `rs-float rs-panel-vars ${this.className}` }, head, this.body, this.actions);
let pos = null;
try { pos = JSON.parse(storage.get(`${this.key}Pos`)); } catch { /* default */ }
document.body.append(this.el);
this.place(pos?.x ?? window.innerWidth - 300, pos?.y ?? 70);
this.drag(head);
this.render();
this.paintMagnet();
this.dock();
}
hide() {
this.el?.remove();
this.el = null;
}
render() { /* subclasses */ }
/** Extra title-bar buttons, before the magnet (subclasses). */
headButtons() {
return [];
}
setMagnet(on) {
this.magnet = on;
storage.set(`${this.key}Magnet`, on ? null : "0");
this.paintMagnet();
for (const c of cards) c.dock();
}
paintMagnet() {
if (!this.magnetBtn) return;
this.magnetBtn.classList.toggle("active", this.magnet);
this.magnetBtn.title = this.magnet ? "Docked: drag to undock" : "Dock in the bottom-right corner";
}
/** Right edge and bottom of the docking corner. */
static anchor(inApp) {
if (inApp) {
const area = box("#linearCenterPanel") ?? { right: window.innerWidth, bottom: window.innerHeight };
return { right: area.right - GAP, bottom: area.bottom - GAP };
}
const minimap = box('[data-testid="minimap-container"]');
const toolbar = toolbarBox();
if (minimap || toolbar) {
// Beside the minimap and zoom toolbar, bottom-aligned with the toolbar, with ComfyUI's gap.
const gap = minimap && toolbar ? Math.max(4, Math.round(toolbar.top - minimap.bottom)) : GAP;
const left = Math.min(minimap?.left ?? Infinity, toolbar?.left ?? Infinity);
return { right: left - gap, bottom: toolbar?.bottom ?? minimap.bottom, gap };
}
const canvas = box("#graph-canvas-container") ?? box("#graph-canvas") ?? { right: window.innerWidth, bottom: window.innerHeight };
return { right: canvas.right - GAP, bottom: canvas.bottom - GAP };
}
dock() {
if (!this.el || !this.magnet) return;
const a = FloatCard.anchor(this.inAppMode());
const gap = a.gap ?? GAP;
// Side by side: to the left of the docked cards created before this one.
let right = a.right;
for (const c of cards) {
if (c === this) break;
if (c.el && c.magnet) right -= c.el.offsetWidth + gap;
}
this.place(right - this.el.offsetWidth, a.bottom - this.el.offsetHeight);
}
place(x, y) {
const w = this.el.offsetWidth || 260;
const hgt = this.el.offsetHeight || 120;
x = Math.max(4, Math.min(window.innerWidth - w - 4, x));
y = Math.max(4, Math.min(window.innerHeight - hgt - 4, y));
this.el.style.left = `${Math.round(x)}px`;
this.el.style.top = `${Math.round(y)}px`;
}
drag(handle) {
handle.addEventListener("pointerdown", (e) => {
if (e.target.closest("button")) return;
e.preventDefault();
const startX = e.clientX - this.el.offsetLeft;
const startY = e.clientY - this.el.offsetTop;
let moved = false;
const move = (ev) => {
if (!moved && this.magnet) this.setMagnet(false); // moving it by hand undocks it
moved = true;
this.place(ev.clientX - startX, ev.clientY - startY);
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
if (moved) storage.set(`${this.key}Pos`, JSON.stringify({ x: this.el.offsetLeft, y: this.el.offsetTop }));
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
}
}
+225 -28
View File
@@ -8,8 +8,38 @@ import { RoomSync } from "./sync.js";
import { Presence } from "./presence.js";
import { RigSharePanel } from "./panel.js";
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 FILES_TAB_ID = "rigshare-files";
/** Remembers the side panel elements each custom tab was rendered into and keeps
* the tab's content in whichever of them is on screen. */
class SlotTracker {
constructor(app, tabs) {
this.app = app;
this.tabs = tabs;
this.slots = Object.fromEntries(Object.keys(tabs).map((id) => [id, new Set()]));
setInterval(() => this.check(), 300);
}
add(id, el) {
this.slots[id]?.add(el);
}
check() {
const active = this.app.extensionManager?.sidebarTab?.activeSidebarTabId;
for (const [id, set] of Object.entries(this.slots)) {
for (const el of set) if (!el.isConnected) set.delete(el);
if (id !== active) continue;
const visible = [...set].find((el) => el.getClientRects().length > 0);
if (visible && !this.tabs[id].has(visible)) this.tabs[id].fill(visible);
}
}
}
const ICON_CLASS = "rigshare-tab-icon";
function setting(id, fallback) {
@@ -21,12 +51,18 @@ function setting(id, fallback) {
}
}
function installAuthHeaders(client) {
function installAuthHeaders(client, sync) {
const original = api.fetchApi.bind(api);
api.fetchApi = (route, options = {}) => {
let headers = options.headers || {};
if (headers instanceof Headers) headers = Object.fromEntries(headers.entries());
return original(route, { ...options, headers: { ...headers, ...client.authHeaders() } });
headers = { ...headers, ...client.authHeaders() };
// Queueing: name the workflow, so the Queue tab can show what each run is.
if (route === "/prompt" && (options.method || "GET").toUpperCase() === "POST") {
const wf = sync.store?.activeWorkflow;
if (wf) headers["X-RigShare-Workflow"] = encodeURIComponent(sync.tabName(wf));
}
return original(route, { ...options, headers });
};
}
@@ -53,6 +89,61 @@ function installManagerGuard(client) {
client.on("self", apply);
client.on("status", apply);
setTimeout(apply, 2000); // Manager registers its commands during setup
// Other plugins put their buttons in the same place as ComfyUI Manager (just
// before ComfyUI's settings group in the top bar): tag them so they are hidden
// the same way without the "manager" permission. Their server-side settings
// are guarded too (PLUGIN_RULES on the server).
const tagPluginButtons = () => {
const settings = app.menu?.settingsGroup?.element;
const bar = settings?.parentElement;
if (!bar) return;
for (const group of bar.children) {
if (group !== settings && group.classList.contains("comfyui-button-group")) group.classList.add("rs-plugin-bar");
}
};
setInterval(tagPluginButtons, 1000);
}
// ComfyUI Manager in its own sidebar tab (admin option): its menu and the custom
// nodes manager open from there, and the top-bar button goes away.
const MANAGER_TAB_ID = "rigshare-manager";
function installManagerTab(client) {
let registered = false;
const run = (id) => app.extensionManager?.command?.execute(id);
const panel = h("div", { class: "rs-panel rs-manager-panel" },
h("div", { class: "rs-header" },
h("div", { class: "rs-title" }, h("i", { class: "pi pi-th-large rs-files-logo" }),
h("div", { class: "rs-title-text" }, h("div", { class: "rs-title-name" }, "ComfyUI Manager"),
h("div", { class: "rs-status" }, "Custom nodes, models, updates")))),
h("div", { class: "rs-body" },
h("section", { class: "rs-section" },
h("button", { class: "rs-btn rs-btn-primary rs-block-btn", onclick: () => run("Comfy.Manager.Menu.ToggleVisibility") },
h("i", { class: "pi pi-th-large" }), "Open Manager"),
h("p", { class: "rs-muted rs-small" }, "Install missing nodes, update everything, the model manager, snapshots and restart."),
h("button", { class: "rs-btn rs-block-btn", onclick: () => run("Comfy.Manager.CustomNodesManager.ToggleVisibility") },
h("i", { class: "pi pi-server" }), "Custom Nodes Manager"),
h("p", { class: "rs-muted rs-small" }, "Browse, install, update, disable or remove custom nodes."))));
const apply = () => {
const manager = app.extensionManager;
const hasManager = (manager?.command?.commands ?? []).some((c) => c.id === "Comfy.Manager.Menu.ToggleVisibility");
const want = !!client.server?.manager_tab && client.online && !!client.perms.manager && hasManager;
document.body.classList.toggle("rs-manager-tab", !!client.server?.manager_tab);
if (want && !registered) {
manager.registerSidebarTab({
id: MANAGER_TAB_ID, icon: "pi pi-th-large", title: "Manager", tooltip: "ComfyUI Manager",
label: "Manager", type: "custom",
render: (el) => { el.classList.add("rs-host"); el.replaceChildren(panel); },
});
registered = true;
} else if (!want && registered) {
(manager.unregisterSidebarTab ?? manager.sidebarTab?.unregisterSidebarTab)?.(MANAGER_TAB_ID);
registered = false;
}
};
client.on("server", apply);
client.on("self", apply);
client.on("status", apply);
setTimeout(apply, 2500); // Manager registers its commands during setup
}
// Comfy.org account: hide the top-bar sign-in button and refuse the sign-in
@@ -90,6 +181,54 @@ 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();
document.body.classList.toggle("rs-hide-file-browsers", hide);
// Pixaroma's workflow browser lists every folder from disk, private ones
// included (the server refuses it to non-admins): its shortcut opens Files.
const pix = (manager()?.command?.commands ?? []).find((c) => c.id === "Pixaroma.OpenWorkflowBrowser");
if (pix && !pix.__rigshare) {
const original = pix.function;
pix.function = (...args) => (hidden() ? openFiles() : original(...args));
pix.__rigshare = true;
}
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) => {
@@ -151,22 +290,6 @@ app.registerExtension({
name: "Comfy.RigShare",
settings: [
{
id: "RigShare.ShareSaved",
category: ["RigShare", "Sharing", "ShareSaved"],
name: "Share saved workflows automatically (everyone opening the same file edits it together)",
type: "boolean",
defaultValue: true,
onChange: (v) => { if (rig) rig.sync.autoShareFiles = v; },
},
{
id: "RigShare.ShareUnsaved",
category: ["RigShare", "Sharing", "ShareUnsaved"],
name: "Share unsaved tabs automatically once they contain nodes",
type: "boolean",
defaultValue: true,
onChange: (v) => { if (rig) rig.sync.autoShareUnsaved = v; },
},
{
id: "RigShare.ShowCursors",
category: ["RigShare", "Presence", "ShowCursors"],
@@ -200,7 +323,22 @@ app.registerExtension({
},
],
menuCommands: [
{ path: ["Workflow"], commands: ["RigShare.OpenFromWorkspace"] },
],
keybindings: [
{ combo: { key: "o", ctrl: true, alt: true }, commandId: "RigShare.OpenFromWorkspace" },
],
commands: [
{
id: "RigShare.OpenFromWorkspace",
label: "Open from RigShare…",
menubarLabel: "Open from RigShare…",
icon: "pi pi-folder-open",
function: () => rig && openFromWorkspace(rig),
},
{
id: "RigShare.OpenChat",
label: "RigShare: Open chat",
@@ -210,12 +348,6 @@ app.registerExtension({
rig?.panel.focusChat();
},
},
{
id: "RigShare.ShareTab",
label: "RigShare: Share this tab",
icon: "pi pi-share-alt",
function: () => rig?.sync.shareCurrent(),
},
],
async setup() {
@@ -230,19 +362,32 @@ app.registerExtension({
rig = { client, sync, presence, panel };
window.rigshare = rig; // handy for debugging from the console
sync.autoShareFiles = setting("RigShare.ShareSaved", true);
sync.autoShareUnsaved = setting("RigShare.ShareUnsaved", true);
presence.showCursors = setting("RigShare.ShowCursors", true);
presence.showNames = setting("RigShare.ShowCursorNames", true);
panel.chatToasts = setting("RigShare.ChatToasts", false);
client.on("toast", (t) => app.extensionManager?.toast?.add({ life: 4000, ...t }));
installAuthHeaders(client);
installAuthHeaders(client, sync);
installQueueGuard(client);
installManagerGuard(client);
installComfyAccountGuard(client);
installFileTabsGuard(client);
installManagerTab(client);
installSaveDialog(app, api, client, sync);
presence.install();
// ComfyUI keeps a separate side panel for the graph and the App view and
// shows the old one again without asking the tab to render: follow the
// visible one, or the panel comes back blank after switching views.
let filesPanelRef = () => null; // set once the Files panel is built below
const slots = new SlotTracker(app, {
[TAB_ID]: { has: (el) => el.contains(panel.root), fill: (el) => panel.mount(el) },
[FILES_TAB_ID]: {
has: (el) => !filesPanelRef() || el.contains(filesPanelRef()),
fill: (el) => { if (el.contains(panel.root)) panel.unmount(); el.replaceChildren(filesPanelRef()); },
},
});
app.extensionManager.registerSidebarTab({
id: TAB_ID,
icon: `pi pi-users ${ICON_CLASS}`,
@@ -251,15 +396,67 @@ app.registerExtension({
label: "RigShare",
type: "custom",
iconBadge: null,
render: (el) => panel.mount(el),
render: (el) => { slots.add(TAB_ID, el); panel.mount(el); },
destroy: () => panel.unmount(),
});
// Files: its own sidebar tab, Drive-style.
const filesBrowser = new FilesBrowser({
app, client, sync, mode: "browse",
onOpenFile: (path) => sync.openPath(path).catch((e) => client.emit("toast", { severity: "error", summary: "RigShare", detail: e.message })),
accessEditor: (folder, close) => panel.accessEditorFor({
load: () => client.request("GET", `/rigshare/api/workspace/folder?path=${encodeURIComponent(folder)}`),
save: (restricted, members) => 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, draftKey: "folderDraft",
}),
});
const filesPanel = h("div", { class: "rs-panel rs-files-panel" },
h("div", { class: "rs-header" },
h("div", { class: "rs-title" }, h("i", { class: "pi pi-folder-open rs-files-logo" }),
h("div", { class: "rs-title-text" }, h("div", { class: "rs-title-name" }, "Files"),
h("div", { class: "rs-status" }, "Your folders and shared ones"))),
h("button", {
class: "rs-btn", title: "Open a workflow file from this computer",
onclick: () => app.extensionManager.command.execute("Comfy.OpenWorkflow"),
}, h("i", { class: "pi pi-upload" }), "From computer")),
h("div", { class: "rs-body" }, filesBrowser.el));
filesPanelRef = () => filesPanel;
let filesLoaded = false;
app.extensionManager.registerSidebarTab({
id: FILES_TAB_ID,
icon: "pi pi-folder-open",
title: "Files",
tooltip: "RigShare files: My files and Shared",
label: "Files",
type: "custom",
render: (el) => {
slots.add(FILES_TAB_ID, el);
el.classList.add("rs-host");
// Same slot element as the RigShare tab when switching between them
// (ComfyUI only calls destroy on unmount): take it over cleanly.
if (el.contains(panel.root)) panel.unmount();
el.replaceChildren(filesPanel);
if (!filesLoaded || client.online) { filesLoaded = true; filesBrowser.reload(); }
},
});
client.on("self", () => { if (filesPanel.isConnected) filesBrowser.reload(); });
// ComfyUI's App view draws buttons only for its own tabs: add ours to its sidebar.
const dock = new AppDock({
app, sync,
views: {
[TAB_ID]: { icon: "pi-users", title: "RigShare", tabId: TAB_ID },
files: { icon: "pi-folder-open", title: "Files", tabId: FILES_TAB_ID },
},
});
// 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 = () => {
+258 -79
View File
@@ -1,19 +1,31 @@
// 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 { ServerWidget } from "./server-widget.js";
import { h, initials, timeAgo, clock, roleChips, meter, storage, viewIcon, APP_ICON } from "./ui.js";
import { ServerWidget, PerfWidget } from "./server-widget.js";
import { perfView, openHistory } from "./perf.js";
import { pickFolder } from "./files.js";
import { isPrivatePath } from "./sync.js";
const ICON_URL = new URL("./assets/icon.svg", import.meta.url).href;
const VIEWS = [
{ id: "workflows", icon: "pi-share-alt", label: "Workflows" },
{ id: "people", icon: "pi-users", label: "People" },
{ id: "workflows", icon: "pi-share-alt", label: "Live" },
{ id: "chat", icon: "pi-comments", label: "Chat" },
{ id: "people", icon: "pi-users", label: "People" },
{ id: "queue", icon: "pi-list", label: "Queue" },
{ id: "server", icon: "pi-server", label: "Server" },
{ 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;
@@ -27,14 +39,27 @@ export class RigSharePanel {
this.historyOpen = false;
this.newKey = null;
this.widget = new ServerWidget(client, () => this.render());
this.widget = new ServerWidget(client, () => this.render(), { inAppMode: () => sync.inAppMode });
this.perfWidget = new PerfWidget(client, () => this.render(), {
inAppMode: () => sync.inAppMode,
workflow: () => { const wf = sync.store?.activeWorkflow; return wf ? sync.tabName(wf) : null; },
});
this.statsAt = Date.now();
client.on("stats", () => { this.statsAt = Date.now(); });
// The Performance clock ticks between server updates (only that section redraws).
setInterval(() => {
if (this.view === "server" && this.visible && this.perfBody?.isConnected && this.client.stats?.performance?.running?.length) {
this.perfBody.replaceChildren(...perfView(this.client.stats.performance, this.statsAt, this.perfWidget.workflow()));
}
}, 250);
this.header = h("div", { class: "rs-header" });
this.nav = h("div", { class: "rs-nav", role: "tablist" });
this.body = h("div", { class: "rs-body" });
this.root = h("div", { class: "rs-panel" }, this.header, this.nav, this.body);
this.chatLog = h("div", { class: "rs-chat-log" });
this.chatTop = h("div", { class: "rs-chat-top" });
this.chatLog = h("div", { class: "rs-chat-log", onscroll: () => this.maybeLoadOlder() });
this.chatInput = h("textarea", {
class: "rs-input rs-chat-input", placeholder: "Message… (Enter to send)", maxLength: 1000, rows: 1,
onkeydown: (e) => {
@@ -49,7 +74,10 @@ export class RigSharePanel {
client.on("status", () => this.render());
client.on("self", () => this.render());
client.on("presence", () => this.renderSoft());
client.on("stats", () => { if (this.view === "server") this.renderBody(); });
client.on("stats", () => {
if (this.view === "server" || this.view === "queue") this.renderBody();
this.renderNav(); // queue count
});
client.on("chat-history", () => this.renderChatLog());
client.on("chat", (m) => this.onChat(m));
sync.addEventListener("state", () => { if (this.view === "workflows") this.renderBody(); });
@@ -61,8 +89,11 @@ export class RigSharePanel {
mount(container) {
container.classList.add("rs-host");
container.append(this.root);
// ComfyUI reuses the same slot element when switching straight between
// two custom sidebar tabs: replace whatever the other tab left there.
container.replaceChildren(this.root);
this.visible = true;
this.renderedView = null; // reopened: the chat starts at the newest message
if (this.view === "chat") this.clearUnread();
this.render();
}
@@ -157,6 +188,7 @@ export class RigSharePanel {
people: c.users.length || null,
chat: this.unread || null,
workflows: c.rooms?.length || null,
queue: c.stats?.queue_items?.length || null,
};
this.nav.replaceChildren(...VIEWS.filter((v) => !v.admin || c.perms.admin).map((v) =>
h("button", {
@@ -185,14 +217,20 @@ export class RigSharePanel {
people: () => this.peopleView(),
chat: () => [this.chatView],
server: () => this.serverView(),
queue: () => this.queueView(),
admin: () => this.adminView(),
account: () => this.accountView(),
};
if (this.view === "admin" && !c.perms.admin) this.view = "workflows";
if ((this.view === "admin" && !c.perms.admin) || !VIEWS.some((v) => v.id === this.view) && this.view !== "account") this.view = "workflows";
const scroll = this.body.scrollTop;
// Re-attaching the chat resets its scroll: keep the reader's place in the history.
const stay = this.view === "chat" && this.renderedView === "chat";
const chatScroll = this.chatLog.scrollTop;
this.body.replaceChildren(...(views[this.view] ?? views.workflows)().filter(Boolean));
this.body.scrollTop = scroll;
if (this.view === "chat") this.scrollChat();
if (stay) this.chatLog.scrollTop = chatScroll;
else if (this.view === "chat") this.scrollChat();
this.renderedView = this.view;
}
section(icon, title, ...children) {
@@ -212,16 +250,23 @@ export class RigSharePanel {
const wf = s.store?.activeWorkflow;
if (!wf) return this.section("pi-file", "This tab", h("p", { class: "rs-muted" }, "No tab open."));
const room = s.target;
const privacy = s.privacyOf(wf);
const head = h("div", { class: "rs-tab-head" },
h("div", { class: "rs-name" }, s.tabName(wf)),
wf.isTemporary ? h("span", { class: "rs-chip" }, "unsaved") : null);
privacy ? h("span", { class: "rs-chip" }, privacy) : null);
const row = h("div", { class: "rs-actions" });
const body = [head];
if (!room) {
body.push(h("p", { class: "rs-muted" }, wf.isTemporary
? "Private until you edit it or share it."
: "Private — not synced with others."));
row.append(h("button", { class: "rs-btn rs-btn-primary", onclick: () => s.shareCurrent() }, h("i", { class: "pi pi-share-alt" }), "Share this tab"));
body.push(h("p", { class: "rs-muted" }, privacy === "unsaved"
? "Not live. Save it outside your private folder to work on it with others."
: privacy === "private"
? "In your private folder: only you see it. Move it out to work on it with others."
: "Not live: this tab is a private copy (no access, or the file was deleted)."));
if (privacy === "private" && c.perms.edit) {
row.append(h("button", {
class: "rs-btn rs-btn-primary", onclick: () => this.moveOutOfPrivate(wf),
}, h("i", { class: "pi pi-share-alt" }), "Move to Shared…"));
}
} else {
const here = c.users.filter((u) => u.room === room.key);
const last = s.lastEditor && Date.now() - s.lastEditor.at < 60000 ? ` · ${s.lastEditor.name} editing` : "";
@@ -229,9 +274,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) body.push(this.accessEditor());
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", {
@@ -245,73 +289,73 @@ export class RigSharePanel {
}
row.append(
h("button", { class: `rs-btn ${this.historyOpen ? "active" : ""}`, onclick: () => { this.historyOpen = !this.historyOpen; this.renderBody(); if (this.historyOpen) this.loadHistory(); } },
h("i", { class: "pi pi-history" }), "History"),
h("button", { class: "rs-btn rs-btn-ghost", title: c.perms.admin ? "Stop sharing this tab (removed from the list if nobody else is in it)" : "Leave: stop syncing this tab and keep a private copy", onclick: () => s.unshareCurrent() },
h("i", { class: "pi pi-eye-slash" }), "Stop sharing"));
h("i", { class: "pi pi-history" }), "History"));
}
this.historyEl ??= h("div", { class: "rs-history" });
return this.section("pi-file", "This tab", ...body, row, room && this.historyOpen ? this.historyEl : null);
}
accessLine() {
const s = this.sync;
const info = s.room;
if (!info) return null;
const owner = info.owner ? this.client.users.find((u) => u.key === info.owner)?.name ?? `@${info.owner}` : null;
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" : "Everyone can open it",
owner ? h("span", { class: "rs-muted" }, ` · owner ${owner}`) : null),
info.manage ? h("button", {
class: `rs-btn rs-btn-sm ${this.accessOpen ? "active" : ""}`,
onclick: () => { this.accessOpen = !this.accessOpen; this.accessDraft = null; this.renderBody(); },
}, "Access") : null);
/** Move a private workflow into a shared location, where it goes live. */
async moveOutOfPrivate(wf) {
const dest = await pickFolder({ app: this.app, client: this.client, sync: this.sync, title: `Move "${this.sync.tabName(wf)}" to…`, start: "@shared" });
if (dest === null || dest === undefined) return;
if (isPrivatePath(`workflows/${dest}/`)) return this.client.emit("toast", { severity: "warn", summary: "RigShare", detail: "Pick a folder outside a private folder." });
const base = wf.path.split("/").pop();
await this.run(() => this.sync.movePath(wf.path, dest ? `workflows/${dest}/${base}` : `workflows/${base}`), "Moved: it is live now");
}
accessEditor() {
/** 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;
const folder = info.folder?.replace(/^shared\//, "");
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));
}
/** 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…"));
const key = this.sync.targetKey;
Promise.all([
this.run(() => this.client.request("GET", `/rigshare/api/room/acl?key=${encodeURIComponent(key)}`)),
this.run(() => this.client.request("GET", "/rigshare/api/people")),
]).then(([acl, people]) => {
Promise.all([this.run(load), this.run(() => this.client.request("GET", "/rigshare/api/people"))]).then(([acl, people]) => {
if (!acl || !people) return;
const draft = this.accessDraft ??= { restricted: acl.restricted, members: { ...acl.members } };
const draft = this[draftKey] ??= { restricted: acl.restricted, members: { ...acl.members } };
const render = () => {
const fixed = people.filter((p) => p.username === acl.owner || p.admin);
const choosable = people.filter((p) => p.username !== acl.owner && !p.admin);
const fixedRows = fixed.map((p) => h("div", { class: "rs-row-item" },
h("div", { class: "rs-grow rs-min0 rs-ellipsis" }, p.display_name, h("span", { class: "rs-muted" }, ` @${p.username}`)),
h("span", { class: "rs-chip" }, p.username === acl.owner ? "Owner · always" : "Admin · always")));
const rows = choosable
.map((p) => {
const select = h("select", {
class: "rs-input rs-select", disabled: !draft.restricted,
onchange: (e) => { if (e.target.value) draft.members[p.username] = e.target.value; else delete draft.members[p.username]; },
}, ...[["", "No access"], ["view", "Can view"], ["edit", "Can edit"]].map(([v, label]) =>
h("option", { value: v, selected: (draft.members[p.username] || "") === v }, label)));
return h("div", { class: "rs-row-item" },
h("div", { class: "rs-grow rs-min0 rs-ellipsis" }, p.display_name, h("span", { class: "rs-muted" }, ` @${p.username}`),
p.edit ? null : h("span", { class: "rs-muted rs-small" }, " · viewer account")),
select);
});
const rows = choosable.map((p) => {
const select = h("select", {
class: "rs-input rs-select", disabled: !draft.restricted,
onchange: (e) => { if (e.target.value) draft.members[p.username] = e.target.value; else delete draft.members[p.username]; },
}, ...[["", "No access"], ["view", "Can view"], ["edit", "Can edit"]].map(([v, label]) =>
h("option", { value: v, selected: (draft.members[p.username] || "") === v }, label)));
return h("div", { class: "rs-row-item" },
h("div", { class: "rs-grow rs-min0 rs-ellipsis" }, p.display_name, h("span", { class: "rs-muted" }, ` @${p.username}`),
p.edit ? null : h("span", { class: "rs-muted rs-small" }, " · viewer account")),
select);
});
const name = `rs-access-${draftKey}`;
const radio = (value, label, hint) => h("label", { class: "rs-check rs-block" },
h("input", { type: "radio", name: "rs-access", checked: draft.restricted === value, onchange: () => { draft.restricted = value; render(); } }),
h("input", { type: "radio", name, checked: draft.restricted === value, onchange: () => { draft.restricted = value; render(); } }),
h("span", {}, label, h("div", { class: "rs-muted rs-small" }, hint)));
box.replaceChildren(...[
radio(false, "Everyone", "Anyone with an account can open it, with their usual permissions"),
radio(false, "Everyone", everyoneHint),
radio(true, "Only people I choose", "The owner and admins always have access"),
draft.restricted ? h("div", { class: "rs-access-list" }, ...fixedRows, ...rows,
rows.length ? null : h("p", { class: "rs-muted rs-small" },
"Everyone else is an admin, and admins always have access. Add accounts without Admin under Admin → Accounts to choose who can open this workflow.")) : null,
"Everyone else is an admin, and admins always have access. Add accounts without Admin under Admin → Accounts to choose who can open it.")) : null,
h("div", { class: "rs-actions" },
h("button", {
class: "rs-btn rs-btn-primary", onclick: async () => {
const ok = await this.run(() => this.sync.setAccess(draft.restricted, draft.restricted ? draft.members : {}), "Access updated");
if (ok !== undefined) { this.accessOpen = false; this.accessDraft = null; this.renderBody(); }
const ok = await this.run(() => save(draft.restricted, draft.restricted ? draft.members : {}), "Access updated");
if (ok !== undefined) { this[draftKey] = null; close(); }
},
}, "Save"),
h("button", { class: "rs-btn rs-btn-ghost", onclick: () => { this.accessOpen = false; this.accessDraft = null; this.renderBody(); } }, "Cancel")),
h("button", { class: "rs-btn rs-btn-ghost", onclick: () => { this[draftKey] = null; close(); } }, "Cancel")),
].filter(Boolean));
};
render();
@@ -331,12 +375,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();
},
@@ -348,7 +392,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() {
@@ -358,27 +411,20 @@ export class RigSharePanel {
const rows = rooms.map((r) => {
const here = r.key === this.sync.targetKey;
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 ${r.kind === "file" ? "pi-file" : "pi-file-edit"} 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),
members.length
? 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-muted rs-small" }, `${timeAgo(r.updated * 1000)} · ${r.nodes} nodes`)),
!here && !members.length && c.perms.admin ? h("button", {
class: "rs-btn rs-btn-icon rs-danger", title: "Stop sharing (remove from this list)",
onclick: async () => {
if (!(await this.confirm("Stop sharing", `Remove "${r.name}" from the shared workflows? A snapshot is kept in its history.`))) return;
await this.run(() => c.request("DELETE", `/rigshare/api/room?key=${encodeURIComponent(r.key)}`), `"${r.name}" is no longer shared`);
},
}, h("i", { class: "pi pi-trash" })) : 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) => 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)),
}, h("i", { class: "pi pi-arrow-right" })));
});
return this.section("pi-share-alt", "Shared workflows",
...(rows.length ? rows : [h("p", { class: "rs-muted" }, "Nothing shared yet. Open a saved workflow or edit a tab.")]));
return this.section("pi-share-alt", "Live workflows",
...(rows.length ? rows : [h("p", { class: "rs-muted" }, "Nobody has a shared workflow open. Any workflow outside a private folder is live while it is open.")]));
}
// ----- people ---------------------------------------------------------
@@ -407,7 +453,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)`,
@@ -431,6 +479,71 @@ export class RigSharePanel {
if (this.client.send({ type: "chat", text })) this.chatInput.value = "";
}
dayLabel(ts) {
const d = new Date(ts);
const today = new Date();
const yesterday = new Date(today.getTime() - 86400000);
if (d.toDateString() === today.toDateString()) return "Today";
if (d.toDateString() === yesterday.toDateString()) return "Yesterday";
return d.toLocaleDateString([], { weekday: "short", year: "numeric", month: "short", day: "numeric" });
}
/** Chat lines for ``messages``, with a date divider wherever the day changes after ``prev``. */
chatLines(messages, prev) {
const out = [];
for (const m of messages) {
if (!prev || new Date(prev.ts).toDateString() !== new Date(m.ts).toDateString()) {
out.push(h("div", { class: "rs-chat-day" }, h("span", {}, this.dayLabel(m.ts))));
}
out.push(this.chatLine(m));
prev = m;
}
return out;
}
renderChatTop() {
const c = this.client;
this.chatTop.replaceChildren(this.loadingOlder
? h("span", { class: "rs-muted rs-small" }, h("i", { class: "pi pi-spin pi-spinner" }), " Loading older messages…")
: c.chatMore
? h("button", { class: "rs-btn rs-btn-sm rs-btn-ghost", onclick: () => this.loadOlder() }, "Load older messages")
: c.chat.length ? h("span", { class: "rs-muted rs-small" }, "Beginning of the chat") : null);
}
maybeLoadOlder() {
if (this.chatLog.scrollTop < 120 && this.client.chatMore && !this.loadingOlder) this.loadOlder();
}
/** Infinite scroll back: prepend the previous page and keep the view where it was. */
async loadOlder() {
if (this.loadingOlder || !this.client.chatMore) return;
this.loadingOlder = true;
this.renderChatTop();
try {
const next = this.client.chat.find((m) => m.seq !== undefined);
const older = await this.client.loadOlderChat();
if (older.length) {
const log = this.chatLog;
const before = log.scrollHeight - log.scrollTop;
// The first message's date divider is redrawn with the older page.
const firstDay = this.chatTop.nextSibling;
if (firstDay?.classList?.contains("rs-chat-day") && next
&& new Date(older.at(-1).ts).toDateString() === new Date(next.ts).toDateString()) firstDay.remove();
this.chatTop.after(...this.chatLines(older));
log.scrollTop = log.scrollHeight - before;
}
} catch (e) {
this.toast("error", e.message || String(e));
} finally {
this.loadingOlder = false;
this.renderChatTop();
}
// Still not enough to scroll: keep going.
requestAnimationFrame(() => {
if (this.chatLog.scrollHeight <= this.chatLog.clientHeight + 120) this.maybeLoadOlder();
});
}
chatLine(m) {
if (m.system) return h("div", { class: "rs-msg rs-msg-system" }, h("span", { class: "rs-time" }, clock(m.ts)), m.text);
const mine = m.from === this.client.self?.id;
@@ -442,8 +555,12 @@ export class RigSharePanel {
}
renderChatLog() {
this.chatLog.replaceChildren(...this.client.chat.slice(-200).map((m) => this.chatLine(m)));
this.chatLog.replaceChildren(this.chatTop, ...this.chatLines(this.client.chat));
this.renderChatTop();
this.scrollChat();
requestAnimationFrame(() => {
if (this.chatLog.isConnected && this.chatLog.scrollHeight <= this.chatLog.clientHeight + 120) this.maybeLoadOlder();
});
}
scrollChat() {
@@ -452,8 +569,9 @@ export class RigSharePanel {
onChat(m) {
const atBottom = this.chatLog.scrollHeight - this.chatLog.scrollTop - this.chatLog.clientHeight < 40;
this.chatLog.append(this.chatLine(m));
while (this.chatLog.childElementCount > 300) this.chatLog.firstChild.remove();
const chat = this.client.chat;
this.chatLog.append(...this.chatLines([m], chat[chat.length - 2]));
if (chat.length === 1) this.renderChatTop();
if (atBottom || m.from === this.client.self?.id) this.scrollChat();
const seen = this.visible && this.view === "chat" && !document.hidden;
if (!seen && !m.system && m.from !== this.client.self?.id) {
@@ -467,6 +585,44 @@ export class RigSharePanel {
// ----- server ---------------------------------------------------------
// ----- queue ----------------------------------------------------------
queueView() {
const c = this.client;
const items = c.stats?.queue_items;
if (items == null) return [this.section("pi-list", "Queue", h("p", { class: "rs-muted" }, "Waiting for the server…"))];
const running = items.filter((it) => it.status === "running");
const pending = items.filter((it) => it.status === "pending");
return [
this.section("pi-play", "Running", ...(running.length ? running.map((it) => this.queueRow(it)) : [h("p", { class: "rs-muted" }, "Nothing running.")])),
this.section("pi-list", `Pending${pending.length ? ` (${pending.length})` : ""}`,
...(pending.length ? pending.map((it, i) => this.queueRow(it, i + 1)) : [h("p", { class: "rs-muted" }, "Nothing waiting.")])),
];
}
queueRow(it, position) {
const c = this.client;
const online = it.user ? c.users.find((u) => u.key === it.user) : null;
const who = it.name || (it.user ? `@${it.user}` : "Unknown");
const mine = it.user && it.user === c.self?.key;
const canCancel = c.perms.queue && (mine || c.perms.admin);
const running = it.status === "running";
const meta = [position ? `#${position}` : null, `${it.nodes} nodes`, it.at ? `queued ${timeAgo(it.at * 1000)}` : null].filter(Boolean).join(" · ");
return h("div", { class: `rs-row-item rs-queue-row ${running ? "running" : ""}` },
h("span", { class: "rs-avatar", style: `background:${online?.color ?? "var(--rs-border)"}`, title: who }, initials(who)),
h("div", { class: "rs-grow rs-min0" },
h("div", { class: "rs-name rs-ellipsis" }, it.workflow || "Workflow",
running ? h("i", { class: "pi pi-spin pi-spinner rs-inline-icon rs-muted", title: "Running" }) : null),
h("div", { class: "rs-muted rs-small rs-ellipsis" }, `${who}${mine ? " (you)" : ""}`),
h("div", { class: "rs-muted rs-small" }, meta)),
canCancel ? h("button", {
class: "rs-btn rs-btn-icon rs-danger", title: running ? "Stop this run" : "Remove from the queue",
onclick: () => this.run(() => running
? c.request("POST", "/api/interrupt", { prompt_id: it.id })
: c.request("POST", "/api/queue", { delete: [it.id] }), running ? "Stopping…" : "Removed from the queue"),
}, running ? h("span", { class: "rs-stop-icon" }) : h("i", { class: "pi pi-times" })) : null);
}
serverView() {
const st = this.client.stats;
const floating = this.widget.enabled;
@@ -488,7 +644,16 @@ export class RigSharePanel {
const q = st.queue;
const queue = q ? h("div", { class: "rs-queue-line" }, h("i", { class: "pi pi-list" }),
q.running || q.pending ? `${q.running} running · ${q.pending} queued` : "Queue idle") : null;
return [this.section("pi-server", "Server", queue, ...items, h("div", { class: "rs-actions" }, pop)),
const perfOn = this.perfWidget.enabled;
const perfPop = h("button", {
class: `rs-btn ${perfOn ? "active" : ""}`, title: "Show the performance card over the canvas",
onclick: () => this.perfWidget.toggle(),
}, h("i", { class: "pi pi-window-maximize" }), perfOn ? "Floating: on" : "Pop out");
this.perfBody = h("div", { class: "rs-perf" }, ...perfView(st.performance, this.statsAt, this.perfWidget.workflow()));
const history = h("button", { class: "rs-btn", title: "Every recorded run", onclick: () => openHistory(this.client) },
h("i", { class: "pi pi-history" }), "History");
return [this.section("pi-stopwatch", "Performance", this.perfBody, h("div", { class: "rs-actions" }, history, perfPop)),
this.section("pi-server", "Server", queue, ...items, h("div", { class: "rs-actions" }, pop)),
gpus.length ? this.section("pi-bolt", "GPUs", ...gpus) : null].filter(Boolean);
}
@@ -588,6 +753,14 @@ export class RigSharePanel {
return [
this.section("pi-users", "Accounts", this.adminAccounts, this.createAccountForm()),
this.section("pi-cog", "Server settings", this.adminSettings),
this.section("pi-comments", "Chat",
h("p", { class: "rs-muted rs-small" }, "The whole chat history is kept. Clearing it deletes every message for everyone; it cannot be undone."),
h("div", { class: "rs-actions" }, h("button", {
class: "rs-btn rs-danger", onclick: async () => {
if (!(await this.confirm("Clear chat history", "Delete every chat message for everyone? This cannot be undone."))) return;
await this.run(() => this.client.request("DELETE", "/rigshare/api/chat"), "Chat history cleared");
},
}, h("i", { class: "pi pi-trash" }), "Clear chat history"))),
];
}
@@ -682,6 +855,8 @@ 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),
managerTab: box(cfg.manager_tab),
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) }),
@@ -699,6 +874,8 @@ 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,
manager_tab: f.managerTab.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,
@@ -713,6 +890,8 @@ 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.managerTab, "ComfyUI Manager in its own sidebar tab", "A Manager tab in the left bar replaces the top-bar button, for accounts with the Manager permission"),
line(f.hideFileTabs, "Hide other workflow browsers", "ComfyUI's Workflows and Apps tabs, and Pixaroma's Workflows panel: RigShare's Files tab replaces them and their shortcuts open Files"),
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),
+101
View File
@@ -0,0 +1,101 @@
// Performance: the run in progress against its ETA, and the last finished runs,
// server-wide. The server sends stats.performance every couple of seconds:
// {running: [{workflow, name, elapsed_ms, eta_ms, runs}], recent: [...],
// etas: {workflow: {ms, runs}}, now}. Between updates the clock keeps ticking here.
import { h } from "./ui.js";
import { modal } from "./files.js";
const MARKS = { done: ["✓", "ok"], stopped: ["■", "bad"], error: ["✕", "bad"] };
export function duration(ms, decimals = 1) {
const total = Math.max(0, ms) / 1000;
const hrs = Math.floor(total / 3600);
const min = Math.floor((total % 3600) / 60);
const sec = total % 60;
const s = decimals > 0 ? sec.toFixed(decimals).padStart(3 + decimals, "0") : String(Math.floor(sec)).padStart(2, "0");
return hrs ? `${hrs}:${String(min).padStart(2, "0")}:${s}` : `${String(min).padStart(2, "0")}:${s}`;
}
function ago(ms) {
const s = Math.max(0, ms / 1000);
if (s < 60) return "just now";
if (s < 3600) return `${Math.floor(s / 60)} min ago`;
if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
return `${Math.floor(s / 86400)} d ago`;
}
/** The run in progress: clock, ETA, progress bar. `since`: ms since the stats arrived. */
function runningBlock(run, since, { big = false, workflow = null } = {}) {
const elapsed = run.elapsed_ms + since;
const eta = run.eta_ms;
const over = eta && elapsed > eta;
const left = eta ? Math.round((eta - elapsed) / 1000) : null;
const mine = !!workflow && run.workflow === workflow; // the workflow on screen: yellow clock
return h("div", { class: `rs-perf-run ${big ? "big" : ""} ${mine ? "mine" : ""}` },
h("div", { class: "rs-perf-top" },
h("span", { class: "rs-perf-clock" }, duration(elapsed), eta ? h("span", { class: "rs-perf-eta" }, ` / ${duration(eta, 0)}`) : null),
h("span", { class: "rs-grow" }),
h("span", { class: `rs-small ${over ? "rs-perf-over" : "rs-muted"}` },
eta ? (left >= 0 ? `~${left} s left` : `${-left} s over`) : "no ETA yet")),
eta ? h("div", { class: `rs-perf-bar ${over ? "over" : ""}` }, h("div", { style: `width:${Math.min(100, (elapsed / eta) * 100)}%` })) : null,
h("div", { class: "rs-muted rs-small rs-ellipsis" },
run.workflow || "Workflow", run.name ? ` · ${run.name}` : "",
eta ? ` · ETA from ${run.runs} run${run.runs > 1 ? "s" : ""}` : ""));
}
function runRow(run, now, { date = false } = {}) {
const [mark, cls] = MARKS[run.result] || ["?", "bad"];
const when = date ? new Date(run.end).toLocaleString([], { dateStyle: "short", timeStyle: "short" }) : ago(now - run.end);
return h("div", { class: "rs-perf-row", title: [run.workflow, run.name, run.result].filter(Boolean).join(" · ") },
h("span", { class: `rs-perf-mark ${cls}` }, mark),
h("span", { class: "rs-perf-name rs-ellipsis" }, run.workflow || "Workflow",
run.name ? h("span", { class: "rs-muted" }, ` · ${run.name}`) : null),
h("b", {}, duration(run.ms)),
h("span", { class: "rs-muted rs-perf-ago" }, when));
}
/** Server tab: the run in progress and the last 5 runs. */
export function perfView(perf, receivedAt, workflow = null) {
if (!perf) return [h("p", { class: "rs-muted rs-small" }, "Waiting for the server…")];
const since = Date.now() - receivedAt;
const out = perf.running.length
? perf.running.map((r) => runningBlock(r, since, { workflow }))
: [h("div", { class: "rs-perf-idle rs-muted" }, "Idle: nothing running")];
if (perf.recent.length) {
out.push(h("div", { class: "rs-perf-head rs-muted rs-small" }, "Last runs"));
out.push(...perf.recent.map((r) => runRow(r, perf.now + since)));
}
return out;
}
/** Floating card: only the timer. Idle, it shows how long the workflow on screen usually takes. */
export function timerView(perf, receivedAt, workflow) {
if (!perf) return [h("p", { class: "rs-muted rs-small" }, "Waiting for the server…")];
const since = Date.now() - receivedAt;
if (perf.running.length) return perf.running.map((r) => runningBlock(r, since, { big: true, workflow }));
const eta = workflow ? perf.etas?.[workflow] : null;
return [h("div", { class: "rs-perf-run big idle" },
h("div", { class: "rs-perf-top" },
h("span", { class: "rs-perf-clock rs-muted" }, eta ? duration(eta.ms) : "--:--"),
h("span", { class: "rs-grow" }),
h("span", { class: "rs-small rs-muted" }, "Idle")),
h("div", { class: "rs-muted rs-small rs-ellipsis" },
workflow ? (eta ? `${workflow} usually takes this · ${eta.runs} run${eta.runs > 1 ? "s" : ""}` : `${workflow}: no timings yet`)
: "Open a workflow to see its usual time"))];
}
/** The full run history, in a window. */
export async function openHistory(client) {
const list = h("div", { class: "rs-perf rs-perf-history" }, h("p", { class: "rs-muted" }, "Loading…"));
modal("Run history", "pi-history", list, null);
try {
const data = await client.request("GET", "/rigshare/api/performance/history?limit=200");
list.replaceChildren(...(data.runs.length
? [h("div", { class: "rs-muted rs-small" }, `${data.total} run${data.total === 1 ? "" : "s"}${data.total > data.runs.length ? `, the last ${data.runs.length} shown` : ""}`),
...data.runs.map((r) => runRow(r, Date.now(), { date: true }))]
: [h("p", { class: "rs-muted" }, "No runs yet.")]));
} catch (e) {
list.replaceChildren(h("p", { class: "rs-muted" }, e.message || String(e)));
}
}
+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;
+103
View File
@@ -0,0 +1,103 @@
// "Save" asks where, with the same folder browser as the Files tab.
//
// ComfyUI's save flow asks the workflow for a file name (promptSave) and saves
// to "<workflow directory>/<name>.json". Returning "<folder>/<name>" makes it
// save straight into the chosen folder, with ComfyUI's own overwrite checks.
import { h, storage } from "./ui.js";
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(/(\.app)?\.json$/i, "").replace(/\.app$/i, "").replace(/[\\/]+/g, "-").slice(0, 120);
}
export function installSaveDialog(app, api, client, sync) {
const patch = () => {
const wf = app.extensionManager?.workflow?.activeWorkflow;
if (!wf) return false;
let proto = Object.getPrototypeOf(wf);
while (proto && !Object.prototype.hasOwnProperty.call(proto, "promptSave")) proto = Object.getPrototypeOf(proto);
if (!proto) return false;
if (proto.__rigshare) return true;
const original = proto.promptSave;
proto.promptSave = async function (...args) {
if (!client.online || !client.self || !this.path?.startsWith("workflows/")) return original.apply(this, args);
try {
return await chooseLocation(app, api, client, sync, this);
} catch (e) {
console.error("[RigShare] save dialog", e);
return original.apply(this, args);
}
};
proto.__rigshare = true;
return true;
};
const timer = setInterval(() => { if (patch()) clearInterval(timer); }, 1000);
}
export async function chooseLocation(app, api, client, sync, wf) {
const directory = wf.directory || "workflows";
const current = directory === "workflows" ? null : directory.slice("workflows/".length);
const start = current ?? storage.get(LAST_FOLDER) ?? `users/${client.self?.key}`;
const name = h("input", { class: "rs-input rs-save-name", value: cleanName(wf.filename) || "Untitled", maxLength: 120,
onkeydown: (e) => { e.stopPropagation(); if (e.key === "Enter") { e.preventDefault(); submit(); } } });
const where = h("span", { class: "rs-muted rs-small rs-grow rs-ellipsis" });
const save = h("button", { class: "rs-btn rs-btn-primary" }, h("i", { class: "pi pi-save" }), "Save here");
const browser = new FilesBrowser({
app, client, sync, mode: "save", start: start === "" ? "@shared" : start,
onPickFile: (entry) => { name.value = entry.name; name.focus(); },
onChange: () => {
save.disabled = browser.saveFolder === null;
where.textContent = browser.saveFolder === null
? "You cannot save in this folder"
: `Save in ${browser.label}`;
},
});
const body = h("div", { class: "rs-save-body" }, h("label", { class: "rs-label" }, "Name"), name, h("label", { class: "rs-label" }, "Location"), browser.el);
const m = modal("Save workflow", "pi-save", body, [where, h("button", { class: "rs-btn rs-btn-ghost", onclick: () => m.close(null) }, "Cancel"), save]);
function submit() {
const n = cleanName(name.value);
if (!n || browser.saveFolder === null) return;
storage.set(LAST_FOLDER, browser.saveFolder);
m.close({ name: n, folder: browser.saveFolder });
}
save.onclick = submit;
await browser.reload();
setTimeout(() => { name.focus(); name.select(); }, 30);
const result = await m.closed;
if (!result) return null;
const targetDir = result.folder ? `workflows/${result.folder}` : "workflows";
if (targetDir === directory) return result.name;
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.
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) {
const store = app.extensionManager.workflow;
await store.syncWorkflows?.();
if (store.getWorkflowByPath?.(path)) {
const dialog = app.extensionManager?.dialog;
const ok = dialog?.confirm
? await dialog.confirm({ title: "Overwrite?", message: `${path.slice("workflows/".length)} already exists. Replace it?` })
: window.confirm(`${path} already exists. Replace it?`);
if (!ok) return null;
}
const state = wf.activeState ?? app.rootGraph?.serialize?.() ?? app.graph.serialize();
await api.storeUserData(path, JSON.stringify(state), { overwrite: true, full_info: true, throwOnError: true });
await store.syncWorkflows?.();
const copy = store.getWorkflowByPath?.(path);
if (copy) {
if (!copy.isLoaded) await copy.load();
await app.loadGraphData(copy.activeState, true, true, copy);
}
client.emit("toast", { severity: "success", summary: "RigShare", detail: `Saved as ${path.slice("workflows/".length)}` });
return null;
}
+78 -63
View File
@@ -1,9 +1,10 @@
// Optional floating server monitor: a small draggable card over the canvas.
// Floating cards over the canvas: the server monitor and performance. Docking,
// dragging and the magnet come from FloatCard (float.js).
import { h, storage } from "./ui.js";
const KEY_ON = "rigshare.serverFloat";
const KEY_POS = "rigshare.serverFloatPos";
import { h } from "./ui.js";
import { FloatCard } from "./float.js";
import { timerView } from "./perf.js";
import { confirmModal } from "./files.js";
function mini(label, pct, text) {
const p = Math.max(0, Math.min(100, pct || 0));
@@ -14,73 +15,41 @@ function mini(label, pct, text) {
h("span", { class: "rs-mini-text" }, text));
}
export class ServerWidget {
constructor(client, onChange) {
/** Server monitor; admins also get ComfyUI's memory actions. */
export class ServerWidget extends FloatCard {
constructor(client, onChange, { inAppMode } = {}) {
super({ key: "rigshare.serverFloat", title: "Server", icon: "pi-server", inAppMode, onChange });
this.client = client;
this.onChange = onChange;
this.enabled = storage.get(KEY_ON) === "1";
this.el = null;
client.on("stats", () => this.render());
if (this.enabled) this.show();
client.on("self", () => this.render());
this.start();
}
toggle() {
this.enabled = !this.enabled;
storage.set(KEY_ON, this.enabled ? "1" : null);
this.enabled ? this.show() : this.hide();
this.onChange?.();
}
show() {
if (this.el) return;
this.body = h("div", { class: "rs-float-body" });
const head = h("div", { class: "rs-float-head" },
h("i", { class: "pi pi-server" }), h("span", { class: "rs-grow" }, "Server"),
h("button", { class: "rs-float-close", title: "Close", onclick: () => this.toggle() }, h("i", { class: "pi pi-times" })));
this.el = h("div", { class: "rs-float rs-panel-vars" }, head, this.body);
let pos = null;
try { pos = JSON.parse(storage.get(KEY_POS)); } catch { /* default */ }
this.place(pos?.x ?? window.innerWidth - 300, pos?.y ?? 70);
document.body.append(this.el);
this.drag(head);
this.render();
}
hide() {
this.el?.remove();
this.el = null;
}
place(x, y) {
const w = this.el.offsetWidth || 260;
const hgt = this.el.offsetHeight || 120;
x = Math.max(4, Math.min(window.innerWidth - w - 4, x));
y = Math.max(4, Math.min(window.innerHeight - hgt - 4, y));
this.el.style.left = `${x}px`;
this.el.style.top = `${y}px`;
return { x, y };
}
drag(handle) {
handle.addEventListener("pointerdown", (e) => {
if (e.target.closest("button")) return;
e.preventDefault();
const startX = e.clientX - this.el.offsetLeft;
const startY = e.clientY - this.el.offsetTop;
const move = (ev) => this.place(ev.clientX - startX, ev.clientY - startY);
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
storage.set(KEY_POS, JSON.stringify({ x: this.el.offsetLeft, y: this.el.offsetTop }));
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
async free(options, done) {
try {
await this.client.request("POST", "/api/free", options);
this.client.emit("toast", { severity: "success", summary: "RigShare", detail: done });
} catch (e) {
this.client.emit("toast", { severity: "error", summary: "RigShare", detail: e.message || String(e) });
}
}
render() {
if (!this.el) return;
const st = this.client.stats;
const admin = !!this.client.perms?.admin;
this.actions.style.display = admin ? "" : "none";
if (admin && !this.actions.childElementCount) {
this.actions.append(
h("button", {
class: "rs-btn rs-btn-sm", title: "Unload the models ComfyUI is holding in memory",
onclick: () => this.free({ unload_models: true }, "Models unloaded"),
}, "Unload models"),
h("button", {
class: "rs-btn rs-btn-sm", title: "Unload the models and clear the node cache (the next run starts from scratch)",
onclick: () => this.free({ unload_models: true, free_memory: true }, "Models and cache freed"),
}, "Free models & cache"));
}
if (!st) {
this.body.replaceChildren(h("div", { class: "rs-muted rs-small" }, "Waiting for data…"));
return;
@@ -97,3 +66,49 @@ export class ServerWidget {
this.body.replaceChildren(...rows);
}
}
/** Performance: only the timer (the history is in the Server tab). */
export class PerfWidget extends FloatCard {
constructor(client, onChange, { inAppMode, workflow } = {}) {
super({ key: "rigshare.perfFloat", title: "Performance", icon: "pi-stopwatch", inAppMode, onChange, className: "rs-float-perf" });
this.client = client;
this.workflow = workflow ?? (() => null); // name of the workflow on screen
this.receivedAt = Date.now();
client.on("stats", () => { this.receivedAt = Date.now(); this.render(); });
// The clock keeps ticking between server updates; the workflow on screen may change.
setInterval(() => { if (this.el) this.render(); }, 250);
this.start();
}
headButtons() {
return [h("button", {
class: "rs-float-close", title: "Clear the timings of the workflow on screen",
onclick: () => this.clear(),
}, h("i", { class: "pi pi-eraser" }))];
}
async clear() {
const wf = this.workflow();
if (!wf) return this.client.emit("toast", { severity: "info", summary: "RigShare", detail: "Open a workflow first." });
const runs = this.client.stats?.performance?.etas?.[wf]?.runs;
const ok = await confirmModal("Clear timings",
`Forget every recorded run of "${wf}"? Its ETA starts over${runs ? ` (it is based on ${runs} run${runs > 1 ? "s" : ""})` : ""}.`,
"Clear", true);
if (!ok) return;
try {
const res = await this.client.request("DELETE", `/rigshare/api/performance?workflow=${encodeURIComponent(wf)}`);
this.client.emit("toast", { severity: "success", summary: "RigShare", detail: `Cleared ${res.cleared} run${res.cleared === 1 ? "" : "s"} of "${wf}"` });
} catch (e) {
this.client.emit("toast", { severity: "error", summary: "RigShare", detail: e.message || String(e) });
}
}
render() {
if (!this.el) return;
const key = JSON.stringify([this.client.stats?.performance?.running, this.client.stats?.performance?.etas, this.workflow()]);
const running = this.client.stats?.performance?.running?.length;
if (!running && key === this.lastKey) return; // idle: nothing moves
this.lastKey = key;
this.body.replaceChildren(...timerView(this.client.stats?.performance, this.receivedAt, this.workflow()));
}
}
+117 -1
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; }
@@ -107,6 +107,14 @@ code { font-family: ui-monospace, monospace; font-size: 11px; background: var(--
/* Comfy.org account sign-in hidden by the admin */
body.rs-hide-comfy-account [data-testid="login-button"],
body.rs-hide-comfy-account [data-testid="login-button-popover"] { display: none !important; }
/* Other plugins' top-bar buttons (rgthree-comfy, Crystools...) hidden like ComfyUI Manager */
body.rs-no-manager .rs-plugin-bar { display: none !important; }
/* Pixaroma's workflow browser button: RigShare's Files tab replaces it */
body.rs-hide-file-browsers .pixwb-group-btn { display: none !important; }
/* ComfyUI Manager moved to its own sidebar tab (admin option): no top-bar button */
body.rs-manager-tab .comfyui-button-group:has(> [title="ComfyUI Manager"]),
body.rs-manager-tab [title="ComfyUI Manager"] { display: none !important; }
.rs-block-btn { width: 100%; justify-content: center; padding: 9px 12px; margin-top: 4px; }
/* ComfyUI Manager hidden for accounts without the "manager" permission */
body.rs-no-manager .comfyui-button-group:has(> [title="ComfyUI Manager"]),
body.rs-no-manager [title="ComfyUI Manager"],
@@ -155,6 +163,9 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; }
.rs-msg.mine .rs-msg-text { border-radius: 10px 4px 10px 10px; background: color-mix(in srgb, var(--rs-accent) 22%, var(--rs-bg2)); margin-left: auto; }
.rs-msg-system { align-self: center; color: var(--rs-muted); font-size: 11px; font-style: italic; }
.rs-time { color: var(--rs-muted); font-size: 10px; font-variant-numeric: tabular-nums; margin-right: 4px; }
.rs-chat-top { display: flex; justify-content: center; align-items: center; min-height: 28px; flex: none; }
.rs-chat-day { display: flex; align-items: center; gap: 8px; color: var(--rs-muted); font-size: 11px; flex: none; }
.rs-chat-day::before, .rs-chat-day::after { content: ""; flex: 1; border-top: 1px solid var(--rs-border); }
.rs-chat-compose { display: flex; gap: 6px; align-items: flex-end; border-top: 1px solid var(--rs-border); padding-top: 8px; }
.rs-chat-input { resize: none; max-height: 120px; min-height: 32px; }
@@ -170,6 +181,61 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; }
.rs-gpu-name .rs-muted { font-weight: 400; font-size: 11px; }
.rs-queue-line { display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 6px 8px; border-radius: 6px; background: var(--rs-bg2); }
/* files */
.rs-folder-block + .rs-folder-block { border-top: 1px solid var(--rs-soft); }
.rs-folder-files { margin-left: 26px; }
.rs-file .rs-select { max-width: 110px; }
/* file browser */
.rs-files-logo { width: 28px; height: 28px; border-radius: 7px; display: grid; place-items: center; font-size: 15px;
background: linear-gradient(135deg, #3b82f6, #a855f7); color: #fff; flex: none; }
.rs-fb { display: flex; flex-direction: column; min-height: 0; }
.rs-fb-tabs { display: flex; gap: 2px; margin-bottom: 8px; border-bottom: 1px solid var(--rs-border); }
.rs-fb-tab { flex: 1 1 0; min-width: 0; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 7px 6px; border: 0;
border-bottom: 2px solid transparent; margin-bottom: -1px; background: none; color: var(--rs-muted); font: inherit; font-size: 12.5px; cursor: pointer; white-space: nowrap; }
.rs-fb-tab span { overflow: hidden; text-overflow: ellipsis; }
.rs-fb-tab:hover { color: var(--rs-fg); }
.rs-fb-tab.active { color: var(--rs-fg); border-bottom-color: var(--rs-accent); font-weight: 600; }
.rs-fb-bar { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.rs-fb-crumbs { flex: 1; min-width: 0; display: flex; align-items: center; flex-wrap: wrap; gap: 2px; }
.rs-fb-crumb { border: 0; background: none; color: var(--rs-muted); font: inherit; font-size: 12.5px; padding: 3px 5px; border-radius: 5px; cursor: pointer; }
.rs-fb-crumb:hover { color: var(--rs-fg); background: var(--rs-bg2); }
.rs-fb-crumb.current { color: var(--rs-fg); font-weight: 650; }
.rs-fb-sep { font-size: 10px; color: var(--rs-muted); }
.rs-fb-actions { display: flex; gap: 4px; }
.rs-fb-tools { margin-bottom: 6px; }
.rs-fb-search { padding: 5px 8px; font-size: 12px; }
.rs-fb-list { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; min-height: 60px; }
.rs-fb-row { display: flex; align-items: center; gap: 6px; border-radius: 7px; position: relative; }
.rs-fb-row:hover { background: color-mix(in srgb, var(--rs-bg2) 70%, transparent); }
.rs-fb-row.disabled { opacity: .45; }
.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: 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; }
.rs-fb-empty { color: var(--rs-muted); font-size: 12px; text-align: center; padding: 24px 8px; }
.rs-modal-wide { max-width: 560px; }
.rs-modal-wide .rs-fb-list { max-height: 46vh; }
.rs-modal-footer { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--rs-border); }
.rs-save-body { display: flex; flex-direction: column; gap: 6px; }
.rs-save-body .rs-fb { border: 1px solid var(--rs-border); border-radius: 8px; padding: 8px; }
/* save dialog */
.rs-modal-backdrop { position: fixed; inset: 0; z-index: 3000; background: rgb(0 0 0 / .55); display: grid; place-items: center; padding: 16px; color: var(--rs-fg); font-size: 13px; }
.rs-modal { width: 100%; max-width: 440px; max-height: 90vh; overflow-y: auto; background: var(--rs-bg); border: 1px solid var(--rs-border);
border-radius: 12px; padding: 18px 18px 14px; box-shadow: 0 20px 60px rgb(0 0 0 / .45); display: flex; flex-direction: column; gap: 6px; }
.rs-modal-title { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 650; margin-bottom: 6px; }
.rs-modal-actions { justify-content: flex-end; margin-top: 10px; }
.rs-save-list { display: flex; flex-direction: column; gap: 4px; max-height: 260px; overflow-y: auto; margin-bottom: 6px; }
.rs-save-option { display: flex; align-items: center; gap: 10px; padding: 7px 9px; border: 1px solid var(--rs-border); border-radius: 8px; cursor: pointer; }
.rs-save-option.on { border-color: var(--rs-accent); background: color-mix(in srgb, var(--rs-accent) 12%, transparent); }
.rs-save-option input { margin: 0; }
.rs-error { color: #f87171; font-size: 12px; }
.rs-error:empty { display: none; }
/* floating server monitor */
.rs-float { position: fixed; z-index: 1000; width: 240px; background: var(--rs-bg); border: 1px solid var(--rs-border);
border-radius: 10px; box-shadow: 0 10px 30px rgb(0 0 0 / .35); font-size: 11px; user-select: none; }
@@ -178,6 +244,36 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; }
.rs-float-head:active { cursor: grabbing; }
.rs-float-close { border: 0; background: none; color: var(--rs-muted); cursor: pointer; padding: 2px; }
.rs-float-close:hover { color: var(--rs-fg); }
.rs-float-magnet { display: grid; place-items: center; border-radius: 4px; }
.rs-float-magnet.active { color: var(--rs-accent); }
.rs-float-actions { display: flex; gap: 4px; padding: 0 8px 8px; flex-wrap: wrap; }
.rs-float-actions .rs-btn { flex: 1; justify-content: center; font-size: 11px; padding: 3px 6px; }
.rs-perf { display: flex; flex-direction: column; gap: 4px; }
.rs-perf-run { display: flex; flex-direction: column; gap: 4px; padding: 6px 8px; border-radius: 8px; background: var(--rs-bg2); }
.rs-perf-top { display: flex; align-items: baseline; gap: 6px; }
.rs-perf-clock { font: 600 20px/1.1 ui-monospace, "SF Mono", Consolas, monospace; font-variant-numeric: tabular-nums; color: #60a5fa; }
.rs-perf-eta { font-size: 13px; font-weight: 500; color: var(--rs-muted); }
.rs-perf-over { color: #f59e0b; }
.rs-perf-run.mine .rs-perf-clock { color: #eab308; }
.rs-perf-run.mine .rs-perf-bar:not(.over) > div { background: #eab308; }
.rs-perf-bar { height: 4px; border-radius: 2px; background: var(--rs-bg); overflow: hidden; }
.rs-perf-bar > div { height: 100%; background: #60a5fa; transition: width .25s linear; }
.rs-perf-bar.over > div { background: #f59e0b; }
.rs-perf-idle { padding: 4px 0; font-size: 12px; }
.rs-perf-head { margin-top: 4px; }
.rs-perf-row { display: grid; grid-template-columns: 14px 1fr auto auto; gap: 6px; align-items: baseline; font-size: 12px; font-variant-numeric: tabular-nums; }
.rs-perf-row b { font-weight: 600; }
.rs-perf-mark.ok { color: #22c55e; } .rs-perf-mark.bad { color: #f59e0b; }
.rs-perf-ago { font-size: 11px; }
.rs-float-perf { width: 330px; }
.rs-perf-run.big .rs-perf-clock { font-size: 30px; }
.rs-perf-run.big .rs-perf-eta { font-size: 17px; }
.rs-perf-run.big .rs-perf-bar { height: 6px; border-radius: 3px; }
.rs-perf-run.idle .rs-perf-clock { color: var(--rs-muted); }
.rs-perf-history { max-height: 60vh; overflow-y: auto; gap: 3px; }
.rs-confirm-text { margin: 4px 0 8px; line-height: 1.5; }
.rs-btn-danger { background: #dc2626; border-color: #dc2626; color: #fff; }
.rs-btn-danger:hover { background: #b91c1c; }
.rs-float-body { padding: 6px 8px 8px; display: flex; flex-direction: column; gap: 4px; }
.rs-mini { display: grid; grid-template-columns: 46px 1fr 62px; align-items: center; gap: 6px; }
.rs-mini-label { color: var(--rs-muted); }
@@ -186,6 +282,26 @@ 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-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-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-stop-icon { display: block; width: 10px; height: 10px; border-radius: 2px; background: currentColor; }
.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)); }
+193 -109
View File
@@ -1,9 +1,9 @@
// Live sync of workflow tabs between browsers.
//
// Every shared tab maps to a room on the server: a saved workflow is the room
// "file:<path>" (everyone opening the same file lands in the same room), an
// unsaved tab joins a room only after "Share this tab" ("tmp:<id>", stored in
// the workflow's extra so it survives reloads and travels with the doc).
// Sharing follows folders: a saved workflow outside your private folder
// (workflows/users/<name>/) is live as soon as it is open, in the room
// "file:<path>" (everyone opening the same file lands in the same room).
// Unsaved tabs and files in a private folder are never synced.
//
// Only the tab on screen is live. Switching tabs leaves the old room and joins
// the new one; the server copy is authoritative when a room already exists.
@@ -15,8 +15,6 @@
// anything structural (added/removed nodes, links, groups, subgraphs) reloads
// the tab from the room document, keeping the local viewport.
const LS_OVERRIDES = "rigshare.shareOverrides";
import { applyPatch, itemId } from "./graphdoc.js";
export { applyPatch };
@@ -82,6 +80,11 @@ export function diffSnapshots(base, snap) {
return Object.keys(patch).length ? patch : null;
}
/** Files in someone's private folder (workflows/users/<name>/...) are never live. */
export function isPrivatePath(path) {
return /^workflows\/users(\/|$)/.test(path || "");
}
function stripView(wf) {
if (wf?.extra) {
delete wf.extra.ds;
@@ -95,11 +98,9 @@ export class RoomSync extends EventTarget {
this.app = app;
this.api = api;
this.client = client;
this.autoShareFiles = true;
this.autoShareUnsaved = true;
this.optedOut = new WeakSet(); // unsaved tabs the user chose to keep private
this.seen = new WeakMap(); // unsaved tab -> nodes when first seen
this.overrides = this.loadOverrides();
this.loading = 0; // a graph load (ours or ComfyUI's) is in progress
this.ownLoad = 0; // the load in progress was started by RigShare
this.installLoadGuard();
this.target = null; // room info of the tab on screen, or null
this.targetWf = null; // that tab's workflow object
@@ -114,17 +115,17 @@ export class RoomSync extends EventTarget {
this.pointerDown = false;
this.lastEditor = null;
this.readOnlyForced = false;
this.room = null; // {role, manage, restricted, owner} of the joined room
this.room = null; // {role, folder, restricted, manage} of the joined room
this.denied = new Set(); // rooms we were refused; not retried until access changes
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) => {
if (msg.room !== this.targetKey) return;
this.room = { role: msg.role, manage: msg.manage, restricted: msg.restricted, owner: msg.owner };
this.room = { role: msg.role, folder: msg.folder, restricted: msg.restricted, manage: msg.manage };
this.updateReadOnly();
this.emitState();
});
@@ -134,7 +135,32 @@ export class RoomSync extends EventTarget {
this.ready = false;
this.room = null;
}
client.emit("toast", { severity: "warn", summary: "RigShare", detail: `You don't have access to "${msg.name}". Your tab stays as a private copy.` });
client.emit("toast", { severity: "warn", summary: "RigShare", detail: msg.reason || `You don't have access to "${msg.name}". Your tab stays as a private copy.` });
this.updateReadOnly();
this.emitState();
});
client.on("room_closed", (msg) => {
// The file was deleted: the tab stays open as a private copy.
if (this.denied.has(msg.room) && !this.findTab({ key: msg.room })) return; // we deleted it and closed the tab
this.denied.add(msg.room);
if (msg.room === this.targetKey) {
this.ready = false;
this.room = null;
}
client.emit("toast", { severity: "info", summary: "RigShare", detail: `"${msg.name}" was deleted. Your tab stays as a private copy.` });
this.updateReadOnly();
this.emitState();
});
client.on("path_moved", (msg) => this.enqueue(() => this.followMove(msg.from, msg.to)));
client.on("path_closed", (msg) => {
// Moved somewhere we cannot open: matching tabs stay as private copies.
const tabs = this.tabsUnder(msg.from);
for (const wf of tabs) this.denied.add(`file:${wf.path}`);
if (tabs.includes(this.targetWf)) {
this.ready = false;
this.room = null;
}
if (tabs.length) client.emit("toast", { severity: "info", summary: "RigShare", detail: `"${this.tabName(tabs[0])}" was moved somewhere you can't open. Your tab stays as a private copy.` });
this.updateReadOnly();
this.emitState();
});
@@ -157,14 +183,6 @@ export class RoomSync extends EventTarget {
this.emitState();
});
client.on("self", () => this.updateReadOnly());
client.on("unshared", (msg) => {
const detail = msg.removed
? `"${msg.name}" is no longer shared.`
: msg.others.length
? `You left "${msg.name}". Still shared with ${msg.others.join(", ")}.`
: `You left "${msg.name}".`;
client.emit("toast", { severity: "info", summary: "RigShare", detail });
});
api.addEventListener("graphChanged", () => this.checkLocal());
window.addEventListener("pointerdown", (e) => {
@@ -205,53 +223,48 @@ 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())));
}
// ----- which room does a tab belong to --------------------------------
loadOverrides() {
try { return JSON.parse(localStorage.getItem(LS_OVERRIDES)) || {}; } catch { return {}; }
}
saveOverrides() {
try { localStorage.setItem(LS_OVERRIDES, JSON.stringify(this.overrides)); } catch { /* ignore */ }
}
tabName(wf) {
return (wf.filename ?? wf.path?.split("/").pop() ?? "Workflow").replace(/\.json$/, "");
return (wf.filename ?? wf.path?.split("/").pop() ?? "Workflow").replace(/(\.app)?\.json$/, "");
}
roomFor(wf, extra) {
/** Why a tab is not live: "unsaved", "private", or null when it is shared. */
privacyOf(wf) {
if (!wf) return null;
if (!wf.isTemporary) {
const shared = this.overrides[wf.path] ?? this.autoShareFiles;
return shared ? { key: `file:${wf.path}`, name: this.tabName(wf), kind: "file" } : null;
}
extra ??= (wf.changeTracker?.activeState ?? wf.activeState)?.extra;
const id = extra?.rigshare?.room;
return id ? { key: id, name: this.tabName(wf), kind: "tmp" } : null;
if (wf.isTemporary || !wf.path?.startsWith("workflows/")) return "unsaved";
return isPrivatePath(wf.path) ? "private" : null;
}
roomFor(wf) {
if (!wf || this.privacyOf(wf)) return null;
return { key: `file:${wf.path}`, name: this.tabName(wf), kind: "file" };
}
activeRoom() {
const wf = this.store?.activeWorkflow;
// For the tab on screen, read the live graph (share flag may be fresh).
const room = this.roomFor(wf, this.graph()?.extra);
const room = this.roomFor(wf);
return { wf, room: room && this.denied.has(room.key) ? null : room };
}
/** Change who can open the room on screen (owner or admin). */
async setAccess(restricted, members) {
if (!this.targetKey) return;
return await this.client.request("PUT", "/rigshare/api/room/acl", { key: this.targetKey, restricted, members });
}
// ----- joining --------------------------------------------------------
tick() {
if (!this.client.online || !this.store) return;
this.maybeAutoShare();
// 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)) {
@@ -286,7 +299,7 @@ export class RoomSync extends EventTarget {
onRoom(msg) {
if (msg.room !== this.targetKey) return;
this.version = msg.version;
this.room = { role: msg.role, manage: msg.manage, restricted: msg.restricted, owner: msg.owner };
this.room = { role: msg.role, folder: msg.folder, restricted: msg.restricted, manage: msg.manage };
if (msg.created || !msg.doc) {
this.doc = this.serialize();
this.ready = true;
@@ -315,11 +328,16 @@ 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 {
await this.app.loadGraphData(doc, true, keepView, wf, { skipAssetScans: true, silentAssetErrors: true });
} finally {
this.ownLoad--;
this.applying--;
if (mode && wf.activeMode !== mode) wf.activeMode = mode;
}
this.settle();
this.updateReadOnly();
@@ -337,7 +355,7 @@ export class RoomSync extends EventTarget {
// ----- local -> remote ------------------------------------------------
checkLocal() {
if (!this.live || this.applying || !this.baseline || !this.client.online) return;
if (!this.live || this.applying || this.loading || !this.baseline || !this.client.online) return;
if (this.store?.activeWorkflow !== this.targetWf) return;
if (performance.now() < this.settleUntil) return;
const snap = snapshotOf(this.serialize());
@@ -436,66 +454,128 @@ export class RoomSync extends EventTarget {
} else if (!lock && this.readOnlyForced) {
canvas.read_only = false;
this.readOnlyForced = false;
this.room = null; // {role, manage, restricted, owner} of the joined room
this.denied = new Set(); // rooms we were refused; not retried until access changes
this.inFlight = 0; // our patches not yet acknowledged
this.crossed = false; // a remote patch arrived while ours were in flight
}
}
// ----- actions --------------------------------------------------------
/** Start sharing the tab on screen. */
shareCurrent() {
const wf = this.store?.activeWorkflow;
if (!wf) return;
if (wf.isTemporary) {
const graph = this.graph();
graph.extra ??= {};
graph.extra.rigshare = { room: `tmp:${crypto.randomUUID?.() ?? Date.now().toString(36)}` };
wf.changeTracker?.checkState();
} else {
this.overrides[wf.path] = true;
this.saveOverrides();
}
this.tick();
/**
* ComfyUI replaces the graph first and only then switches the active tab
* (opening a file, a template, drag-and-drop, switching tabs). Without a
* guard, that half-finished state looks like "someone replaced the whole
* shared workflow" and would be sent to the room of the old tab.
*/
installLoadGuard() {
const app = this.app;
const original = app.loadGraphData.bind(app);
app.loadGraphData = async (graphData, ...rest) => {
const own = this.ownLoad > 0;
// Content coming from outside (a file from disk, a template, a paste)
// must not drag a RigShare room id along with it.
const target = rest[2];
if (!own && (target === undefined || target === null || typeof target === "string")
&& graphData && typeof graphData === "object" && graphData.extra?.rigshare) {
graphData = { ...graphData, extra: { ...graphData.extra } };
delete graphData.extra.rigshare;
}
this.loading++;
try {
return await original(graphData, ...rest);
} finally {
this.loading--;
if (!own) {
// Re-evaluate the tab on screen from scratch: rejoin its room
// (the server copy wins) instead of diffing against the old tab.
this.baseline = null;
this.ready = false;
this.targetKey = undefined;
this.settleUntil = performance.now() + 1500;
setTimeout(() => this.tick(), 50);
}
}
};
}
/** Open a saved workflow file (like ComfyUI's workflow browser does). */
async openPath(path) {
const store = this.store;
await store.syncWorkflows?.();
const wf = store.getWorkflowByPath?.(path);
if (!wf) throw new Error("That workflow no longer exists");
if (wf === store.activeWorkflow) return;
if (!wf.isLoaded) await wf.load();
await this.app.loadGraphData(wf.activeState, true, true, wf);
}
/** Move a saved workflow to another folder (ComfyUI's own rename). */
async movePath(path, target) {
const store = this.store;
await store.syncWorkflows?.();
const wf = store.getWorkflowByPath?.(path);
if (!wf) throw new Error("That workflow no longer exists");
if (store.getWorkflowByPath?.(target)) throw new Error("A workflow with that name already exists there");
if (store.renameWorkflow) await store.renameWorkflow(wf, target);
else await wf.rename(target);
await store.syncWorkflows?.();
}
/** Open saved tabs at ``path`` or inside it (a folder). */
tabsUnder(path) {
return (this.store?.openWorkflows || []).filter((wf) =>
!wf.isTemporary && (wf.path === path || wf.path?.startsWith(path + "/")));
}
/**
* Unsaved tabs join a room on their own once someone edits them. Tabs that
* are merely open (the default workflow, restored tabs) stay private so the
* shared list is not flooded with untouched copies.
* We are deleting a file (or folder): close its tabs first, without the
* "unsaved changes" prompt (the user already confirmed the delete). The
* active tab goes through ComfyUI's own close, which opens the next tab.
*/
maybeAutoShare() {
const wf = this.store.activeWorkflow;
if (!this.autoShareUnsaved || !wf?.isTemporary || this.optedOut.has(wf)) return;
const graph = this.graph();
if (!graph || graph.extra?.rigshare?.room) return;
const sig = JSON.stringify(graph.serialize().nodes);
const seen = this.seen.get(wf);
const now = performance.now();
// Keep re-baselining while the tab is still loading/settling.
if (!seen || now - seen.at < 3000) {
this.seen.set(wf, { sig, at: seen?.at ?? now });
if (seen) seen.sig = sig;
return;
async closeTabsUnder(path) {
const store = this.store;
const tabs = this.tabsUnder(path);
if (!tabs.length) return;
for (const wf of tabs) {
this.denied.add(`file:${wf.path}`); // our own delete: no "private copy" notice
if (wf === store.activeWorkflow) continue;
store.closeWorkflow?.(wf);
}
const active = store.activeWorkflow;
if (active && tabs.includes(active)) {
active.isModified = false;
await this.app.extensionManager?.command?.execute("Workspace.CloseWorkflow");
}
if (sig !== seen.sig && graph.nodes?.length) this.shareCurrent();
}
/** Stop sharing the tab on screen (keeps a private copy). */
unshareCurrent() {
const wf = this.store?.activeWorkflow;
if (!wf) return;
if (this.targetKey) this.client.send({ type: "unshare", room: this.targetKey });
if (wf.isTemporary) {
this.optedOut.add(wf);
delete this.graph().extra?.rigshare;
wf.changeTracker?.checkState();
} else {
this.overrides[wf.path] = false;
this.saveOverrides();
/**
* Someone renamed or moved a file (or folder) we have open: point our tabs
* at the new path. The file already moved on the server, so ComfyUI's own
* rename runs with the server call skipped; it still updates the tab list,
* drafts, thumbnails and bookmarks.
*/
async followMove(from, to) {
const store = this.store;
let moved = false;
for (const wf of this.tabsUnder(from)) {
const target = to + wf.path.slice(from.length);
if (target === wf.path) continue;
moved = true;
this.denied.delete(`file:${target}`);
if (store?.renameWorkflow && typeof wf.updatePath === "function") {
wf.rename = async (path) => { wf.updatePath(path); return wf; };
try {
await store.renameWorkflow(wf, target);
} finally {
delete wf.rename;
}
} else if (typeof wf.updatePath === "function") {
wf.updatePath(target);
} else {
wf.path = target;
}
}
if (!moved) return;
// Rejoin under the new name (or leave, if it went private).
this.targetKey = undefined;
this.tick();
}
@@ -508,30 +588,34 @@ export class RoomSync extends EventTarget {
return null;
}
/** Open a room from the list (switching to its tab if already open). */
/** Open a live workflow from the list (switching to its tab if already open). */
async openRoom(room) {
const store = this.store;
let wf = this.findTab(room);
if (wf && wf === store.activeWorkflow) return;
const data = await this.client.request("GET", `/rigshare/api/room?key=${encodeURIComponent(room.key)}`);
if (!wf && room.kind === "file") {
if (!wf) {
const path = room.key.slice(5);
if (this.overrides[path] === false) {
delete this.overrides[path];
this.saveOverrides();
}
await store.syncWorkflows?.();
wf = store.getWorkflowByPath?.(path) ?? null;
if (wf && !wf.isLoaded) await wf.load();
}
if (wf) {
await this.app.loadGraphData(data.doc ?? wf.activeState, true, true, wf);
this.ownLoad++;
try {
await this.app.loadGraphData(data.doc ?? wf.activeState, true, true, wf);
} finally {
this.ownLoad--;
}
} else {
// Unsaved shared tab (or a file that no longer exists): open a
// temporary tab carrying the room id.
// The file no longer exists: open its last live state as an unsaved copy.
const doc = structuredClone(data.doc || {});
doc.extra = { ...(doc.extra || {}), rigshare: { room: room.key } };
await this.app.loadGraphData(doc, true, true, `${room.name}.json`);
this.ownLoad++;
try {
await this.app.loadGraphData(doc, true, true, `${room.name}.json`);
} finally {
this.ownLoad--;
}
}
this.tick();
}
+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}` });
}