20 Commits
Author SHA1 Message Date
Edouard Durand 9f70cf9205 RigShare 1.2.0
Snapshot on every save (Saved at <time>), New folder prompt above the Save
dialog, lighter Graph marker, changelog, docs, README and screenshots.
2026-09-26 06:26:47 +00:00
Edouard Durand e533e64970 App icon for workflows with an App set up; smaller icons in folder lists 2026-09-26 04:32:38 +00:00
Edouard Durand d6ef325ee1 Files: access button inside a shared folder
The listing reports the shared folder you are in; if you manage it, a lock
button beside New folder opens its access list above the content.
2026-09-26 04:09:18 +00:00
Edouard Durand c1b2bf0d5e Folder access only in the Files tab; refresh screenshots
The Live tab keeps the restricted/open line but no longer edits the folder's
access list. Screenshots show the Files access editor and ComfyUI's Graph and
App icons.
2026-09-26 04:06:24 +00:00
Edouard Durand aadedc9f03 Show who is in the node graph or the App view
Clients report their view; the Live tab marks each avatar and People shows a
Graph/App chip, with ComfyUI's own Graph and App icons (also used for apps
in Files and the Live list).
2026-09-26 04:01:19 +00:00
Edouard Durand 45aebb0776 App mode: RigShare and Files buttons in the App view's own sidebar
Copies ComfyUI's sidebar button so they look the same, keeps the unread badge
and selected state, and opens the panels in a drawer beside the sidebar. The
floating dock remains only as a fallback when the sidebar can't be found.
2026-09-26 03:56:04 +00:00
Edouard Durand 435acb0914 App mode: dock for the RigShare panel and Files
ComfyUI's App view shows a fixed set of sidebar tabs (assets, apps), so
extension tabs vanish there. While the tab on screen is in the App view, a
small dock opens the RigShare panel and Files in a drawer, with the unread
chat badge.
2026-09-26 03:51:51 +00:00
Edouard Durand 51c16fa93e App mode support; hide ComfyUI's Workflows and Apps tabs
Live edits keep each person's graph/App view, cursors are off in the App
view, and .app.json workflows show as apps in Files, the Live list and Save.
ComfyUI's own file tabs duplicate Files, so they are hidden by default (admin
setting hide_comfy_file_tabs) and their shortcuts open Files.
2026-09-26 03:43:46 +00:00
Edouard Durand 6833141e58 README: remove the TODO list 2026-09-26 03:32:35 +00:00
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
52 changed files with 3829 additions and 471 deletions
+5
View File
@@ -0,0 +1,5 @@
# Kept in git, left out of the Comfy Registry package
docs/
.github/
.gitattributes
tests/
+2 -2
View File
@@ -3,7 +3,7 @@ on:
workflow_dispatch:
push:
branches:
- main
- master
paths:
- "pyproject.toml"
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
with:
+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"
+58
View File
@@ -1,5 +1,63 @@
# Changelog
## 1.2.0 (2026-09-26)
ComfyUI's App mode works with RigShare, and every save keeps a snapshot.
### Added
- **App mode**: live workflows work in ComfyUI's App view. Each person keeps their own graph or App view through live edits, App inputs sync, and the app's layout (chosen inputs and outputs) syncs with the workflow. Cursors are off in the App view
- In the App view, where ComfyUI shows only its own sidebar tabs, **RigShare** and **Files** buttons join that sidebar, with the unread chat badge, and open the panels in a drawer beside it
- The Live tab and People show whether each person is in the node graph or the App view, with ComfyUI's own Graph and App icons
- Workflows with an App set up, and workflows saved as Apps (`.app.json`), show ComfyUI's App icon in Files and the Live list. Save keeps an App an App
- **A snapshot on every save**, named *Saved at* and the time (in each viewer's time zone). They rotate like automatic snapshots (`snapshot_keep`)
- Inside a shared folder you own, its 🔒 access button sits next to the new-folder button
### Changed
- ComfyUI's own Workflows and Apps sidebar tabs are hidden, since Files replaces them, and their shortcuts open Files. Admin setting `hide_comfy_file_tabs`, on by default. The server enforces file permissions either way
- Folder access is managed only in the Files tab. *This tab* in the Live tab still says whether the folder is restricted
- Smaller icons in folder lists
### Fixed
- In the Save and Move dialogs, **New folder** asked for the name in a prompt hidden behind the dialog
- Snapshots taken in the same second could be rotated out of order
## 1.1.0 (2026-09-26)
Workflows now live in folders, like a shared drive, and sharing follows them.
### 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.
+28 -29
View File
@@ -4,11 +4,11 @@
# 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>
@@ -18,14 +18,17 @@ A custom node that turns a single ComfyUI server into a shared workspace. Everyo
## 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
- 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
@@ -36,28 +39,32 @@ 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/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</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/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></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 +84,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 +98,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 +116,3 @@ I run one ComfyUI box for several people. [ComfyUI-Nexus](https://github.com/dax
- it hid menus with CSS and monkeypatched litegraph internals that no longer exist
- anyone could become admin with a shared password typed into chat
- everybody worked on one canvas, and opening ComfyUI wiped your own workflow
## TODO
- [x] Login page, JWT sessions, first-run admin setup
- [x] Per-user API keys
- [x] 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:
+2 -2
View File
@@ -8,7 +8,7 @@
| **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 |
| **Admin** | Everything, plus deleting workflow files, 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.
+2 -2
View File
@@ -46,10 +46,10 @@ 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 |
| `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: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 123 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: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 72 KiB

+11 -2
View File
@@ -49,12 +49,21 @@ 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 |
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
+5 -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,14 @@ Admins change these under **Admin → Server settings**. They are stored in `rig
| `guest_permissions` | `{"edit": false, "queue": false}` | What guests may do |
| `broadcast_execution` | `true` | Send progress and previews to everyone, not only whoever queued |
| `hide_comfy_account` | `false` | Hide ComfyUI's Comfy.org sign-in button and dialog |
| `hide_comfy_file_tabs` | `true` | Hide ComfyUI's own Workflows and Apps sidebar tabs; RigShare's Files tab replaces them and their shortcuts open it |
| `hide_api_templates` | `false` | Remove templates that need paid API nodes from the template browser |
| `api_protection.enabled` | `true` | Server-side checks on queue, interrupt, free and upload calls |
| `api_protection.trusted_ips` | `["127.0.0.1", "::1"]` | Addresses or networks that skip login and checks |
| `room_expiry_days` | `14` | Forget 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 |
## Environment variables
+21 -20
View File
@@ -1,22 +1,23 @@
# 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.
**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 +29,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 the panels in a drawer beside it.
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.
+5 -2
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"
version = "1.2.0"
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"]
+100
View File
@@ -0,0 +1,100 @@
"""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 __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
+140 -112
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.
@@ -15,18 +15,20 @@ import json
import logging
import secrets
import time
from collections import deque
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 +36,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 +58,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 +76,13 @@ class Client:
self.joined = time.time()
self.idle = False
self.room = None
self.view = "graph" # "graph" (node editor) or "app" (ComfyUI's App view)
def public(self):
return {"id": self.id, "kind": self.kind, "key": self.key if self.kind == "user" else None,
"guest_id": self.key if self.kind == "guest" else None,
"name": self.name, "color": self.color, "perms": self.perms,
"joined": int(self.joined), "idle": self.idle, "room": self.room}
"joined": int(self.joined), "idle": self.idle, "room": self.room, "view": self.view}
class Hub:
@@ -91,18 +92,20 @@ 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)
# ----- lifecycle ----------------------------------------------------
@@ -128,9 +131,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}")
@@ -224,66 +224,68 @@ 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.
"""
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 +308,60 @@ 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
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)
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,27 +371,11 @@ 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)),
"hide_api_templates": bool(cfg.get("hide_api_templates"))}
async def broadcast_server(self):
@@ -348,8 +383,7 @@ class Hub:
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 +392,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 +413,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 +428,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,14 +451,16 @@ 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 = Room(key, clean_text(data.get("name"), 120) or key, "file", doc)
room.dirty = True
self.rooms[key] = room
created = True
@@ -436,25 +476,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 +494,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 +506,12 @@ class Hub:
await self.broadcast_presence()
await self.system_chat(f"{old} is now known as {name}")
elif kind == "view":
view = data.get("view")
if view in ("graph", "app") and view != client.view:
client.view = view
await self.broadcast_presence()
elif kind == "idle":
client.idle = bool(data.get("idle"))
await self.broadcast_presence()
+275 -63
View File
@@ -12,6 +12,8 @@ from collections import defaultdict
from aiohttp import web
from .workspace import Workspace, norm
log = logging.getLogger("ComfyUI-RigShare")
# (method, path) prefixes that need a permission when api_protection is on.
@@ -22,9 +24,6 @@ PROTECTED = [
("POST", "/free", "queue"),
("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
@@ -292,7 +291,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 +307,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 +344,155 @@ 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.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)))
return err or 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))
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}")
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()
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 +502,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", ""))
@@ -481,6 +580,110 @@ 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 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
@@ -492,15 +695,11 @@ def setup(server, store, hub):
raise web.HTTPFound(f"/rigshare/login?next={path}")
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)
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 +719,20 @@ def setup(server, store, hub):
"details": "", "extra_info": {}}, "node_errors": {}},
status=403)
break
if userdata and userdata[0] == "save":
await request.read() # keep the body for the snapshot below (aiohttp caches it)
response = await handler(request)
# Every save of a workflow keeps a snapshot, named after when it was saved.
if userdata and userdata[0] == "save" and getattr(response, "status", 0) == 200:
snapshot_saved_file(request, userdata[1], await request.read())
# Workflow listings only show what this person may open.
if userdata and userdata[0] == "list" and not trusted(request.remote or "") and hub.workspace:
response = filter_listing(request, response, userdata[1])
# 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))
# 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")):
+15 -6
View File
@@ -47,13 +47,15 @@ DEFAULT_CONFIG = {
},
# Hide ComfyUI's own Comfy.org account sign-in (top-bar button and dialog).
"hide_comfy_account": False,
# ComfyUI's own Workflows and Apps sidebar tabs duplicate RigShare's Files tab.
"hide_comfy_file_tabs": True,
# Remove templates that need paid API nodes from the template browser.
"hide_api_templates": False,
# Rooms nobody opened for this many days are forgotten.
"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
}
@@ -442,8 +444,11 @@ class Store:
items.append({"id": name[:-5], **meta})
return items
def add_snapshot(self, doc, author, room, room_name, label=None):
snap_id = time.strftime("%Y%m%d-%H%M%S") + "-" + secrets.token_hex(2)
def add_snapshot(self, doc, author, room, room_name, label=None, kind=None):
"""``kind`` "save": taken when the file was saved; those rotate like automatic ones."""
# Sorted by name, newest first: include microseconds so saves in the same second keep their order.
now = time.time()
snap_id = time.strftime("%Y%m%d-%H%M%S", time.localtime(now)) + f"-{int(now % 1 * 1e6):06d}-" + secrets.token_hex(2)
meta = {
"time": int(time.time()),
"author": author,
@@ -452,6 +457,8 @@ class Store:
"room_name": room_name,
"nodes": len(doc.get("nodes", [])) if isinstance(doc, dict) else 0,
}
if kind:
meta["kind"] = kind
with open(os.path.join(self.snapshot_dir, snap_id + ".json"), "w", encoding="utf-8") as f:
json.dump({"meta": meta, "doc": doc}, f)
self._prune_snapshots(room)
@@ -468,9 +475,11 @@ class Store:
def _prune_snapshots(self, room):
keep = max(1, int(self.config.get("snapshot_keep", 30)))
# Labelled (manual) snapshots are kept; only automatic ones rotate.
auto = [s for s in self.list_snapshots(room) if not s.get("label")]
for snap in auto[keep:]:
# Named (manual) snapshots are kept; automatic and on-save ones rotate, each on its own.
snaps = self.list_snapshots(room)
auto = [s for s in snaps if not s.get("label") and not s.get("kind")]
saves = [s for s in snaps if s.get("kind") == "save"]
for snap in auto[keep:] + saves[keep:]:
try:
os.remove(os.path.join(self.snapshot_dir, snap["id"] + ".json"))
except OSError:
+430
View File
@@ -0,0 +1,430 @@
"""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"^[\w][\w .()-]{0,63}$")
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
def norm(path):
"""Decode and normalise a userdata-relative path ('' for the root)."""
path = full_unquote(str(path or "")).replace("\\", "/")
return posixpath.normpath("/" + path).lstrip("/") if path else ""
def has_app(doc):
"""A workflow with an App set up (ComfyUI App mode: chosen inputs or outputs)."""
data = (doc or {}).get("extra") if isinstance(doc, dict) else None
linear = (data or {}).get("linearData") if isinstance(data, dict) else None
return isinstance(linear, dict) and bool(linear.get("inputs") or linear.get("outputs"))
def min_role(a, b):
return a if ROLE_RANK[a] <= ROLE_RANK[b] else b
class Workspace:
def __init__(self, store, root):
self.store = store
self.root = root # absolute path of the workflows directory
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 = os.path.join(self.root, *rel_dir.split("/")) 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 os.path.join(self.root, *[p for p in rel.split("/") if p])
def _file_has_app(self, full):
"""Read a workflow file only when it changed since the last check."""
try:
st = os.stat(full)
except OSError:
return False
cached = self._app_cache.get(full)
if cached and cached[0] == st.st_mtime and cached[1] == st.st_size:
return cached[2]
try:
with open(full, "r", encoding="utf-8") as f:
text = f.read()
found = '"linearData"' in text and has_app(json.loads(text))
except (OSError, ValueError):
found = False
self._app_cache[full] = (st.st_mtime, st.st_size, found)
return found
def _entry(self, rel, name, is_dir, kind, key, perms):
full = self._abs(rel)
is_app = not is_dir and name.endswith(".app.json") # saved as an App (ComfyUI App mode)
entry = {"type": "folder" if is_dir else "file", "path": rel,
"name": name if is_dir else name[:-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 = os.path.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 = os.path.join(self.root, *folder.split("/"))
dst = os.path.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 = os.path.join(self.root, *folder.split("/"))
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: [] });
});
+130
View File
@@ -0,0 +1,130 @@
// 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");
});
+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))
+199
View File
@@ -0,0 +1,199 @@
"""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 FakePromptServer:
def __init__(self):
self.app = web.Application()
self.sent = []
def send_sync(self, event, data, sid=None):
self.sent.append((event, sid))
def comfy_routes(user_root):
"""Minimal ComfyUI /userdata routes for the single default user."""
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})
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")(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))
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}"
+442
View File
@@ -0,0 +1,442 @@
"""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())
+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)
+133
View File
@@ -0,0 +1,133 @@
"""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"
+150
View File
@@ -0,0 +1,150 @@
// RigShare in ComfyUI's App mode.
//
// The App view's sidebar shows a fixed set of tabs (assets and apps), so the
// RigShare and Files tabs disappear there. While the tab on screen is in the
// App view, RigShare adds its own buttons to that sidebar, copied from
// ComfyUI's so they look the same, and opens the panels in a drawer next to it.
// If the sidebar can't be found, a small floating dock offers them instead.
import { h } from "./ui.js";
const RAIL = 'nav[data-testid="side-toolbar"]';
export class AppDock {
/**
* views: { id: { icon, title, mount(el), unmount() } }
*/
constructor({ sync, views }) {
this.sync = sync;
this.views = views;
this.open = null;
this.badges = {};
this.railButtons = {};
this.dockButtons = {};
this.body = h("div", { class: "rs-host rs-dock-drawer-body" });
this.drawer = h("div", { class: "rs-dock-drawer rs-panel-vars", role: "dialog" },
h("button", { class: "rs-dock-close", title: "Close", onclick: () => this.show(null) }, h("i", { class: "pi pi-times" })),
this.body);
this.dock = h("div", { class: "rs-dock rs-panel-vars" }, ...Object.entries(views).map(([id, v]) => {
const badge = h("span", { class: "rs-dock-badge" });
const btn = h("button", {
class: "rs-dock-btn", title: v.title, "aria-label": v.title,
onclick: () => this.toggle(id),
}, h("i", { class: `pi ${v.icon}` }), badge);
this.dockButtons[id] = { btn, badge };
return btn;
}));
this.dock.style.display = "none";
this.drawer.style.display = "none";
document.body.append(this.dock, this.drawer);
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && this.open) this.show(null); });
setInterval(() => this.update(), 400);
}
toggle(id) {
this.show(this.open === id ? null : id);
}
update() {
const active = this.sync.inAppMode;
const rail = active ? document.querySelector(RAIL) : null;
if (rail) this.addToRail(rail);
else this.removeFromRail();
this.dock.style.display = active && !rail ? "" : "none";
if (!active && this.open) this.show(null);
if (this.open) this.place(rail);
}
/** Copy one of ComfyUI's sidebar buttons for each view, placed after its own tabs. */
addToRail(rail) {
const ours = Object.values(this.railButtons);
if (ours.length && ours.every((b) => rail.contains(b.btn))) return;
const tabs = [...rail.querySelectorAll('[data-testid$="-tab-button"]')].filter((b) => !b.dataset.rigshare);
const template = tabs.at(-1);
if (!template) return;
this.removeFromRail();
let after = template;
for (const [id, view] of Object.entries(this.views)) {
const btn = template.cloneNode(true);
btn.removeAttribute("data-testid");
btn.dataset.rigshare = id;
btn.classList.remove("side-bar-button-selected");
btn.setAttribute("aria-label", view.title);
btn.title = view.title;
const icon = btn.querySelector(".side-bar-button-icon");
if (icon) {
const mine = document.createElement("i");
mine.className = `pi ${view.icon} side-bar-button-icon`;
mine.style.fontSize = "18px";
icon.replaceWith(mine);
}
btn.querySelector(".sidebar-icon-badge")?.remove();
const label = [...btn.querySelectorAll("span")].find((s) => s.textContent.trim());
if (label) label.textContent = view.title;
const badge = document.createElement("span");
badge.className = "sidebar-icon-badge rs-rail-badge";
const holder = btn.querySelector(".side-bar-button-icon")?.parentElement ?? btn;
holder.style.position = "relative"; // the badge sits on the icon's corner
holder.append(badge);
btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); this.toggle(id); });
after.after(btn);
after = btn;
this.railButtons[id] = { btn, badge };
}
this.refreshButtons();
}
removeFromRail() {
for (const b of Object.values(this.railButtons)) b.btn.remove();
this.railButtons = {};
}
/** Beside the sidebar when there is one, above the floating dock otherwise. */
place(rail) {
const d = this.drawer.style;
if (rail) {
const r = rail.getBoundingClientRect();
d.left = `${Math.round(r.right + 8)}px`;
d.top = `${Math.round(r.top + 8)}px`;
d.bottom = "8px";
} else {
d.left = "64px";
d.top = "8px";
d.bottom = "64px";
}
}
show(id) {
if (this.open) this.views[this.open].unmount?.();
this.open = id;
this.refreshButtons();
this.drawer.style.display = id ? "" : "none";
if (id) {
this.place(document.querySelector(RAIL));
this.views[id].mount(this.body);
} else {
this.body.replaceChildren();
}
}
refreshButtons() {
for (const [id, b] of Object.entries(this.railButtons)) {
b.btn.classList.toggle("side-bar-button-selected", id === this.open);
this.paintBadge(b.badge, this.badges[id]);
}
for (const [id, b] of Object.entries(this.dockButtons)) {
b.btn.classList.toggle("active", id === this.open);
this.paintBadge(b.badge, this.badges[id]);
}
}
paintBadge(el, text) {
el.textContent = text || "";
el.style.display = text ? "block" : "none";
}
setBadge(id, text) {
this.badges[id] = text || null;
this.refreshButtons();
}
}
+15 -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 || {};
@@ -146,7 +148,6 @@ export class RigClient extends EventTarget {
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 +201,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(/^\//, ""), {
+396
View File
@@ -0,0 +1,396 @@
// 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(); });
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;
if (e.type === "folder") {
await this.run(() => this.client.request("POST", "/rigshare/api/workspace/delete-folder", { path: e.path }), "Deleted");
} else {
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");
}
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 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;
}
+113 -24
View File
@@ -8,6 +8,10 @@ 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 ICON_CLASS = "rigshare-tab-icon";
@@ -90,6 +94,45 @@ function installComfyAccountGuard(client) {
setTimeout(apply, 2000);
}
// ComfyUI's Workflows and Apps sidebar tabs browse the same files as RigShare's
// Files tab, without its folders. The server enforces permissions either way;
// hiding them avoids two file browsers and buttons the server would refuse.
const COMFY_FILE_TABS = ["workflows", "apps"];
function installFileTabsGuard(client) {
const removed = new Map();
const manager = () => app.extensionManager;
const tabs = () => manager()?.getSidebarTabs?.() ?? manager()?.sidebarTab?.sidebarTabs ?? [];
const hidden = () => client.server?.hide_comfy_file_tabs !== false;
const openFiles = () => manager()?.command?.execute("Workspace.ToggleSidebarTab.rigshare-files");
const apply = () => {
if (!client.server) return; // wait for the server's settings
const hide = hidden();
for (const id of COMFY_FILE_TABS) {
const tab = tabs().find((t) => t.id === id);
// Open workflow tabs can be shown in the Workflows sidebar tab: keep it then.
const needed = id === "workflows" && setting("Comfy.Workflow.WorkflowTabsPosition", "Topbar") === "Sidebar";
if (hide && tab && !needed) {
removed.set(id, tab);
(manager().unregisterSidebarTab ?? manager().sidebarTab.unregisterSidebarTab)(id);
} else if ((!hide || needed) && !tab && removed.has(id)) {
manager().registerSidebarTab(removed.get(id));
removed.delete(id);
}
// Their keyboard shortcuts open Files instead.
const cmd = (manager()?.command?.commands ?? []).find((c) => c.id === `Workspace.ToggleSidebarTab.${id}`);
if (cmd && !cmd.__rigshare) {
const original = cmd.function;
cmd.function = (...args) => (removed.has(id) ? openFiles() : original(...args));
cmd.__rigshare = true;
}
}
};
client.on("server", apply);
// ComfyUI registers its own tabs around the time extensions load.
let tries = 0;
const timer = setInterval(() => { apply(); if (++tries > 15) clearInterval(timer); }, 1000);
}
function installQueueGuard(client) {
const original = api.queuePrompt.bind(api);
api.queuePrompt = async (...args) => {
@@ -151,22 +194,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 +227,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 +252,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,8 +266,6 @@ 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);
@@ -241,6 +275,8 @@ app.registerExtension({
installQueueGuard(client);
installManagerGuard(client);
installComfyAccountGuard(client);
installFileTabsGuard(client);
installSaveDialog(app, api, client, sync);
presence.install();
app.extensionManager.registerSidebarTab({
@@ -255,11 +291,64 @@ app.registerExtension({
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));
let filesLoaded = false;
app.extensionManager.registerSidebarTab({
id: "rigshare-files",
icon: "pi pi-folder-open",
title: "Files",
tooltip: "RigShare files: My files and Shared",
label: "Files",
type: "custom",
render: (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 hides extension sidebar tabs: offer them in a dock there.
const dock = new AppDock({
sync,
views: {
[TAB_ID]: { icon: "pi-users", title: "RigShare", mount: (el) => panel.mount(el), unmount: () => panel.unmount() },
files: {
icon: "pi-folder-open", title: "Files",
mount: (el) => { el.replaceChildren(filesPanel); filesBrowser.reload(); },
},
},
});
// The registered tab object is wrapped by Vue; mutating it through the
// store keeps the unread badge reactive.
panel.onBadge = (badge) => {
const tab = app.extensionManager.getSidebarTabs?.().find((t) => t.id === TAB_ID);
if (tab) tab.iconBadge = badge;
dock.setBadge(TAB_ID, badge);
setBlink(!!badge);
};
panel.onNotify = () => {
+176 -74
View File
@@ -1,19 +1,29 @@
// Sidebar panel: tabs for workflows, people, chat, server and admin, plus an
// account view (profile, password, API keys) behind the avatar button.
import { h, initials, timeAgo, clock, roleChips, meter, storage } from "./ui.js";
import { h, initials, timeAgo, clock, roleChips, meter, storage, viewIcon, APP_ICON } from "./ui.js";
import { ServerWidget } from "./server-widget.js";
import { pickFolder } from "./files.js";
import { isPrivatePath } from "./sync.js";
const ICON_URL = new URL("./assets/icon.svg", import.meta.url).href;
const VIEWS = [
{ id: "workflows", icon: "pi-share-alt", label: "Workflows" },
{ id: "workflows", icon: "pi-share-alt", label: "Live" },
{ id: "people", icon: "pi-users", label: "People" },
{ id: "chat", icon: "pi-comments", label: "Chat" },
{ 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;
@@ -34,7 +44,8 @@ export class RigSharePanel {
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) => {
@@ -61,8 +72,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();
}
@@ -188,11 +202,16 @@ export class RigSharePanel {
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 +231,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 +255,8 @@ export class RigSharePanel {
s.live
? h("span", { class: "rs-live" }, h("span", { class: "rs-live-dot" }), `Live · rev ${s.version}${last}`)
: h("span", { class: "rs-live rs-muted" }, h("i", { class: "pi pi-spin pi-spinner" }), "Joining…"),
h("span", { class: "rs-avatars" }, ...here.map((u) => h("span", { class: "rs-avatar rs-avatar-xs", style: `background:${u.color}`, title: u.name }, initials(u.name))))));
h("span", { class: "rs-avatars" }, ...here.map((u) => this.viewAvatar(u)))));
body.push(this.accessLine());
if (this.accessOpen && s.room?.manage) 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 +270,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 +356,12 @@ export class RigSharePanel {
}
this.historyEl.replaceChildren(...snaps.map((snap) => h("div", { class: "rs-row-item" },
h("div", { class: "rs-grow rs-min0" },
h("div", { class: "rs-ellipsis" }, snap.label || "Auto", h("span", { class: "rs-muted" }, ` · ${snap.nodes} nodes`)),
h("div", { class: "rs-ellipsis" }, snapLabel(snap), h("span", { class: "rs-muted" }, ` · ${snap.nodes} nodes`)),
h("div", { class: "rs-muted rs-small" }, `${timeAgo(snap.time * 1000)} · ${snap.author}`)),
h("button", { class: "rs-btn rs-btn-icon", title: "Open a private copy", onclick: () => this.openSnapshot(snap) }, h("i", { class: "pi pi-external-link" })),
this.sync.canEdit ? h("button", {
class: "rs-btn rs-btn-icon", title: "Restore for everyone", onclick: async () => {
if (!(await this.confirm("Restore snapshot", `Restore "${snap.label || "Auto"}" for everyone in this workflow?`))) return;
if (!(await this.confirm("Restore snapshot", `Restore "${snapLabel(snap)}" for everyone in this workflow?`))) return;
await this.run(() => this.client.request("POST", `/rigshare/api/snapshots/${snap.id}/restore`), "Snapshot restored");
this.loadHistory();
},
@@ -348,7 +373,16 @@ export class RigSharePanel {
if (!data) return;
const doc = structuredClone(data.doc);
if (doc.extra) delete doc.extra.rigshare; // a private copy, not the live room
await this.app.loadGraphData(doc, true, true, `${snap.room_name || "Snapshot"} (${snap.label || snap.id}).json`);
await this.app.loadGraphData(doc, true, true, `${snap.room_name || "Snapshot"} (${snapLabel(snap).replace(/[\/:]/g, "-")}).json`);
}
/** Small avatar marked with how that person views the workflow: node graph or App. */
viewAvatar(u) {
const app = u.view === "app";
return h("span", {
class: `rs-avatar rs-avatar-xs rs-view-avatar ${app ? "rs-view-app" : ""}`, style: `background:${u.color}`,
title: `${u.name} · ${app ? "App view" : "editing the graph"}`,
}, initials(u.name), h("span", { class: "rs-view-mark" }, viewIcon(u.view)));
}
roomsSection() {
@@ -358,27 +392,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 +434,9 @@ export class RigSharePanel {
h("div", { class: "rs-name rs-ellipsis" }, u.name, self ? h("span", { class: "rs-muted" }, " (you)") : null,
u.idle ? h("i", { class: "pi pi-moon rs-muted rs-inline-icon", title: "Away" }) : null),
h("div", { class: "rs-chips" }, ...(toggles.length ? toggles : roleChips(u.perms))),
h("div", { class: "rs-where" }, h("i", { class: `pi ${where ? "pi-file" : "pi-lock"}` }), h("span", { class: "rs-ellipsis" }, where ?? "private tab"))),
h("div", { class: "rs-where" }, h("i", { class: `pi ${where ? "pi-file" : "pi-lock"}` }), h("span", { class: "rs-ellipsis" }, where ?? "private tab"),
where ? h("span", { class: `rs-chip rs-view-chip ${u.view === "app" ? "rs-view-app" : ""}` },
viewIcon(u.view), u.view === "app" ? "App" : "Graph") : null)),
self ? null : h("button", {
class: `rs-btn rs-btn-icon ${following ? "rs-btn-primary" : ""}`,
title: following ? "Stop following" : `Follow ${u.name} (jump to their tab and view)`,
@@ -431,6 +460,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 +536,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 +550,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) {
@@ -682,6 +781,7 @@ export class RigSharePanel {
broadcast: box(cfg.broadcast_execution),
protect: box(cfg.api_protection?.enabled),
hideAccount: box(cfg.hide_comfy_account),
hideFileTabs: box(cfg.hide_comfy_file_tabs ?? true),
hideApiTemplates: box(cfg.hide_api_templates),
trusted: text((cfg.api_protection?.trusted_ips || []).join(", "), "127.0.0.1, 172.17.0.0/16"),
interval: h("input", { class: "rs-input rs-num", type: "number", min: 0, value: Math.round((cfg.snapshot_interval_sec ?? 300) / 60) }),
@@ -699,6 +799,7 @@ export class RigSharePanel {
allow_guests: f.guests.checked,
broadcast_execution: f.broadcast.checked,
hide_comfy_account: f.hideAccount.checked,
hide_comfy_file_tabs: f.hideFileTabs.checked,
hide_api_templates: f.hideApiTemplates.checked,
api_protection: { enabled: f.protect.checked, trusted_ips: f.trusted.value.split(/[\s,]+/).filter(Boolean) },
snapshot_interval_sec: Math.max(0, +f.interval.value || 0) * 60,
@@ -713,6 +814,7 @@ export class RigSharePanel {
line(f.broadcast, "Share execution progress", "Everyone sees progress and previews, not only whoever queued"),
line(f.protect, "Enforce permissions on the ComfyUI API", "Blocks queueing, interrupting and uploads for users without permission"),
line(f.hideAccount, "Hide the Comfy.org account login", "Removes ComfyUI's own sign-in button and dialog (used for paid API nodes)"),
line(f.hideFileTabs, "Hide ComfyUI's Workflows and Apps tabs", "RigShare's Files tab replaces them. Their shortcuts open Files instead"),
line(f.hideApiTemplates, "Hide API templates", "Removes templates that need paid API nodes from the template browser"),
h("label", { class: "rs-label" }, "Trusted IPs / networks (skip login and checks)"), f.trusted,
h("div", { class: "rs-row" }, h("label", { class: "rs-label rs-grow" }, "Auto snapshot every (min, 0 = off)"), f.interval),
+3 -3
View File
@@ -71,7 +71,7 @@ export class Presence extends EventTarget {
sendState() {
if (!this.client.online || !this.app.canvas) return;
let msg;
if (!this.sync.live || document.hidden) {
if (!this.sync.live || document.hidden || this.sync.inAppMode) {
msg = { type: "cursor", x: null, y: null };
} else {
const [x, y] = this.app.canvas.graph_mouse ?? [0, 0];
@@ -104,7 +104,7 @@ export class Presence extends EventTarget {
}
applyViewport(vp, graphId) {
if (graphId !== this.currentGraphId()) return;
if (graphId !== this.currentGraphId() || this.sync.inAppMode) return;
const canvas = this.app.canvas;
const rect = canvas.canvas.getBoundingClientRect();
canvas.ds.scale = vp.scale;
@@ -123,7 +123,7 @@ export class Presence extends EventTarget {
draw(ctx) {
const visible = new Set();
if (!this.sync.live || !this.showCursors) return this.pruneCursorEls(visible);
if (!this.sync.live || !this.showCursors || this.sync.inAppMode) return this.pruneCursorEls(visible);
const canvas = this.app.canvas;
const graph = canvas.graph;
const scale = canvas.ds.scale;
+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;
}
+84 -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; }
@@ -155,6 +155,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 +173,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; }
@@ -186,6 +244,31 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; }
.rs-mini-queue { color: var(--rs-muted); text-align: center; margin-top: 2px; }
/* remote cursors (DOM layer above canvas content) */
.rs-dock { position: fixed; left: 64px; bottom: 16px; z-index: 1100; display: flex; gap: 4px; padding: 4px;
background: var(--rs-bg); border: 1px solid var(--rs-border); border-radius: 12px; box-shadow: 0 6px 24px rgb(0 0 0 / .35); }
.rs-dock-btn { position: relative; width: 36px; height: 36px; display: grid; place-items: center; border: 0; border-radius: 8px;
background: none; color: var(--rs-muted); cursor: pointer; }
.rs-dock-btn .pi { font-size: 16px; }
.rs-dock-btn:hover { color: var(--rs-fg); background: var(--rs-bg2); }
.rs-dock-btn.active { color: var(--rs-accent-fg); background: var(--rs-accent); }
.rs-dock-badge { display: none; position: absolute; top: 1px; right: 1px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px;
background: #ef4444; color: #fff; font-size: 10px; line-height: 16px; font-weight: 700; }
.rs-dock-drawer { position: fixed; top: 8px; bottom: 64px; left: 64px; width: min(380px, calc(100vw - 80px)); z-index: 1100;
background: var(--rs-bg); border: 1px solid var(--rs-border); border-radius: 12px; box-shadow: 0 12px 40px rgb(0 0 0 / .45); overflow: hidden; }
.rs-dock-drawer-body { height: 100%; }
.rs-dock-drawer .rs-header { padding-right: 40px; }
.rs-rail-badge { position: absolute; top: -4px; right: -6px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px;
background: #ef4444; color: #fff; font-size: 10px; line-height: 16px; font-weight: 700; text-align: center; }
.rs-dock-close { position: absolute; top: 8px; right: 8px; z-index: 2; border: 0; background: none; color: var(--rs-muted); cursor: pointer; padding: 4px; }
.rs-dock-close:hover { color: var(--rs-fg); }
.rs-view-avatar { position: relative; overflow: visible; }
.rs-comfy-icon { display: inline-block; width: 1em; height: 1em; flex: none; }
.rs-view-mark { position: absolute; right: -6px; bottom: -5px; font-size: 8px; width: 13px; height: 13px; display: grid; place-items: center;
border-radius: 50%; background: #71717a; color: #fff; border: 1.5px solid var(--rs-bg); }
.rs-view-app .rs-view-mark { color: var(--rs-accent-fg); background: var(--rs-accent); }
.rs-view-chip { display: inline-flex; align-items: center; gap: 3px; margin-left: 6px; font-size: 10.5px; padding: 0 6px; }
.rs-view-chip .rs-comfy-icon { font-size: 11px; }
.rs-view-chip.rs-view-app { color: var(--rs-accent); border-color: var(--rs-accent); }
.rs-cursor-layer { position: fixed; inset: 0; pointer-events: none; z-index: 900; overflow: hidden; }
.rs-cursor { position: absolute; left: 0; top: 0; will-change: transform; transition: transform 70ms linear; }
.rs-cursor svg { display: block; filter: drop-shadow(0 1px 1px rgb(0 0 0 / .4)); }
+174 -112
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,31 @@ 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.
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 +182,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 +222,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 +298,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 +327,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 +354,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 +453,107 @@ 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.
* 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.
*/
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;
}
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();
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 +566,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}` });
}