11 Commits
Author SHA1 Message Date
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
49 changed files with 3295 additions and 443 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"
+34
View File
@@ -1,5 +1,39 @@
# Changelog
## 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.
+31 -20
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,16 @@ 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 the whole chat history, loading older messages as you scroll up, with an unread badge, a blinking icon and a sound
- keeps per-workflow history: snapshots every few minutes, named snapshots, one-click restore
- 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 +38,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 +83,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 +97,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
@@ -110,10 +120,11 @@ I run one ComfyUI box for several people. [ComfyUI-Nexus](https://github.com/dax
- [x] Login page, JWT sessions, first-run admin setup
- [x] Per-user API keys
- [x] Every workflow shared, per-workflow snapshots
- [x] Live co-editing of every shared workflow, per-workflow snapshots
- [x] Cursors, selections, follow mode
- [x] Chat with badge, blink and sound
- [x] Chat with badge, blink and sound, whole history with scroll back
- [x] Server monitor with floating widget
- [x] Conflict-free node ids when two people add nodes at the same instant
- [x] Per-workflow access lists (share with specific people)
- [ ] Publish to the Comfy registry
- [x] Private folder per account, shared folders, Save asks where
- [x] Folder access lists (share with specific people)
- [x] 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 |
+56
View File
@@ -0,0 +1,56 @@
# 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>
Click a workflow to open it. The Files tab marks live workflows with a green dot. **From computer** opens a workflow file from your machine, like ComfyUI's own *Open*.
Hover a row for its actions: **Rename**, **Move to…**, **Delete**, and on a shared folder you own, 🔒 **Who can open it**. The folder button creates a folder. In the Shared tab it creates a shared folder that you own.
## 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.
## 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: 124 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: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 122 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: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 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
+3 -5
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.
@@ -32,10 +30,10 @@ Admins change these under **Admin → Server settings**. They are stored in `rig
| `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 |
| `chat_keep` | `0` | Chat messages kept: `0` keeps the whole history, a number trims it to the latest ones at startup |
## Environment variables
+18 -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.
@@ -30,26 +31,23 @@ Only the tab on screen is live. Switch to another tab and you leave the room; sw
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), or with **Folder access** under *This tab* when the workflow on screen is in a folder you own. Choose *Only people I choose* and give each person **Can view** or **Can edit**.
- 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.
+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.1.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
+130 -111
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
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:
@@ -91,18 +91,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 +130,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 +223,67 @@ 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))})
out.sort(key=lambda r: (-len(r["members"]), -r["updated"]))
return out
@@ -306,11 +306,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")
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,23 +369,6 @@ 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"),
@@ -348,8 +380,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 +389,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 +410,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 +425,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 +448,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 +473,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 +491,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":
+256 -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,96 @@ 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 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 +681,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
@@ -521,6 +706,14 @@ def setup(server, store, hub):
status=403)
break
response = await handler(request)
# 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")):
+1 -1
View File
@@ -53,7 +53,7 @@ DEFAULT_CONFIG = {
"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
}
+392
View File
@@ -0,0 +1,392 @@
"""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 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 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)
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 _entry(self, rel, name, is_dir, kind, key, perms):
full = self._abs(rel)
entry = {"type": "folder" if is_dir else "file", "path": rel,
"name": name if is_dir 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))
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()))
return {"path": rel, "role": role, "entries": entries, "can_mkdir": can_mkdir}
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}"
+326
View File
@@ -0,0 +1,326 @@
"""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())
+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"
+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(/^\//, ""), {
+360
View File
@@ -0,0 +1,360 @@
// 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 } 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));
this.actions.replaceChildren(...[
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));
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(" · ")
: [timeAgo(e.modified * 1000), e.role === "view" ? "view only" : null].filter(Boolean).join(" · ");
const icon = isFolder ? (e.restricted ? "pi-lock" : "pi-folder") : "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: `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 = "") {
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);
};
const onKey = (e) => { if (e.key === "Escape" && backdrop.isConnected) { 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 };
}
/** 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;
}
+59 -24
View File
@@ -8,6 +8,9 @@ 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";
const TAB_ID = "rigshare";
const ICON_CLASS = "rigshare-tab-icon";
@@ -151,22 +154,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 +187,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 +212,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 +226,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 +235,7 @@ app.registerExtension({
installQueueGuard(client);
installManagerGuard(client);
installComfyAccountGuard(client);
installSaveDialog(app, api, client, sync);
presence.install();
app.extensionManager.registerSidebarTab({
@@ -255,6 +250,46 @@ 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(); });
// The registered tab object is wrapped by Vue; mutating it through the
// store keeps the unread badge reactive.
panel.onBadge = (badge) => {
+163 -68
View File
@@ -3,11 +3,13 @@
import { h, initials, timeAgo, clock, roleChips, meter, storage } 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" },
@@ -34,7 +36,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 +64,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 +194,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 +223,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` : "";
@@ -231,7 +249,7 @@ export class RigSharePanel {
: 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))))));
body.push(this.accessLine());
if (this.accessOpen && s.room?.manage) body.push(this.accessEditor());
if (this.accessOpen && s.room?.manage && s.room.folder) body.push(this.accessEditor(s.room.folder));
if (!s.canEdit) body.push(h("p", { class: "rs-note" }, h("i", { class: "pi pi-lock" }), c.perms.edit ? "View only in this workflow." : "View only — ask an admin for edit access."));
if (s.canEdit) {
row.append(h("button", {
@@ -245,73 +263,87 @@ 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: that is decided by its folder. */
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),
info.manage ? h("button", {
class: `rs-btn rs-btn-sm ${this.accessOpen ? "active" : ""}`, title: `Who can open the folder "${folder}"`,
onclick: () => { this.accessOpen = !this.accessOpen; this.accessDraft = null; this.renderBody(); },
}, "Folder access") : null);
}
accessEditor(folder) {
return this.accessEditorFor({
load: () => this.client.request("GET", `/rigshare/api/workspace/folder?path=${encodeURIComponent(folder)}`),
save: (restricted, members) => this.client.request("PATCH", "/rigshare/api/workspace/folders", { path: folder, restricted, members }),
everyoneHint: "Anyone with an account can open its workflows, with their usual permissions",
close: () => { this.accessOpen = false; this.accessDraft = null; this.renderBody(); },
draftKey: "accessDraft",
});
}
/** Access editor for a shared folder (Files tab and the live workflow's folder). */
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();
@@ -358,27 +390,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: "pi pi-file rs-row-icon" }),
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) => h("span", { class: "rs-avatar rs-avatar-xs", style: `background:${u.color}`, title: u.name }, initials(u.name))))),
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 ---------------------------------------------------------
@@ -431,6 +456,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 +532,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 +546,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) {
+101
View File
@@ -0,0 +1,101 @@
// "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";
function cleanName(name) {
return (name || "").trim().replace(/\.json$/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.
return await saveCopyElsewhere(app, api, client, wf, `${targetDir}/${result.name}.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;
}
+58
View File
@@ -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: 16px; color: var(--rs-muted); width: 18px; 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; }
+159 -110
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,7 +115,7 @@ 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
@@ -124,7 +125,7 @@ export class RoomSync extends EventTarget {
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) => {
@@ -211,47 +228,32 @@ export class RoomSync extends EventTarget {
// ----- 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$/, "");
}
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();
const { wf, room } = this.activeRoom();
const key = room?.key ?? null;
if (key !== this.targetKey || (key && wf !== this.targetWf)) {
@@ -286,7 +288,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;
@@ -316,9 +318,11 @@ export class RoomSync extends EventTarget {
const ds = this.app.canvas?.ds;
if (keepView && ds) doc.extra = { ...(doc.extra || {}), ds: { scale: ds.scale, offset: [...ds.offset] } };
this.applying++;
this.ownLoad++;
try {
await this.app.loadGraphData(doc, true, keepView, wf, { skipAssetScans: true, silentAssetErrors: true });
} finally {
this.ownLoad--;
this.applying--;
}
this.settle();
@@ -337,7 +341,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 +440,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 +553,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();
}