diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..97b1bec15d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +**/node_modules +**/dist +.git +.pnpm-store +release +reports +tests/e2e/.features-gen +third-party-licenses diff --git a/AGENTS.md b/AGENTS.md index 786f239c14..c0f14faadb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,8 @@ OpenCloud Web is a TypeScript/Vue 3 single-page application that serves as the b │ ├── tsconfig/ # Shared TypeScript configuration │ ├── extension-sdk/ # Utilities for custom extensions │ └── web-app-*/ # Standalone apps/extensions (files, search, preview, …) +├── services/ # Server-side sidecars shipped as their own docker images +│ └── realtime/ # Hocuspocus/Yjs server for realtime collaboration ├── tests/ │ └── e2e/ # End-to-end tests (Playwright + Playwright BDD) ├── dev/ # Docker/infrastructure config for local development diff --git a/dev/docker/opencloud/proxy.yaml b/dev/docker/opencloud/proxy.yaml index 543e108350..6224cd5fac 100644 --- a/dev/docker/opencloud/proxy.yaml +++ b/dev/docker/opencloud/proxy.yaml @@ -1,9 +1,12 @@ -# This adds four additional routes to the proxy. Forwarding -# request on '/carddav/', '/caldav/' and the respective '/.well-knwown' -# endpoints to the radicale container and setting the required headers. +# This adds five additional routes to the proxy: '/realtime' to the realtime +# container, and '/caldav/', '/carddav/' plus their respective '/.well-known' +# endpoints to radicale. Also sets the headers those backends require. additional_policies: - name: default routes: + - endpoint: /realtime + backend: http://realtime:1234 + unprotected: true - endpoint: /caldav/ backend: http://host.docker.internal:5232 remote_user_header: X-Remote-User diff --git a/dev/docs/collaboration.md b/dev/docs/collaboration.md new file mode 100644 index 0000000000..6312dbd193 --- /dev/null +++ b/dev/docs/collaboration.md @@ -0,0 +1,374 @@ +# Realtime collaboration + +This doc explains the architecture and implementation of realtime collaboration in OpenCloud Web. It is intended for developers who want to understand how it works, or who want to implement their own collaborative app on top of the same infrastructure. + +## Yjs + +Collaboration is built on [Yjs](https://yjs.dev/), a CRDT framework for building collaborative applications. Yjs provides the data structures and algorithms for merging concurrent edits from multiple clients, ensuring that all clients eventually converge to the same state. + +## System view + +```mermaid +flowchart TB + subgraph browsers["Browsers"] + direction LR + A["Client A
web + text-editor"] + B["Client B
web + text-editor"] + end + + subgraph oc["OpenCloud"] + direction TB + GRAPH["LibreGraph API"] + DAV["WebDAV"] + end + + HP["Yjs server
in-memory replica per room
never touches the file"] + + A -->|"1 . load + save file
GET / PUT, If-Match etag"| DAV + B -->|"1 . load + save file"| DAV + A <-->|"2 . CRDT sync + awareness
websocket + bearer token"| HP + B <-->|"2 . CRDT sync + awareness"| HP + HP -->|"3 . who are you?"| GRAPH + HP -->|"4 . may you write this?"| GRAPH + + classDef client fill:#e8f0fe,stroke:#4285f4,color:#111 + classDef server fill:#e6f4ea,stroke:#34a853,color:#111 + classDef side fill:#fef7e0,stroke:#f9ab00,color:#111 + class A,B client + class GRAPH,DAV server + class HP side +``` + +### The Yjs server + +The [OpenCloud Yjs server](https://github.com/opencloud-eu/web/tree/main/services/realtime) runs a [Hocuspocus](https://tiptap.dev/docs/hocuspocus) server that relays Yjs updates between clients editing the same file. + +It is reachable at whatever URL the deployment configures. In the Web dev setup that is `/realtime` on the OpenCloud host, forwarded by OC's own proxy, but it could equally be a separate domain behind its own ingress. The server URL needs to be defined via the `WEB_OPTION_YJS_SERVER_URL` environment variable. + +| It does | It does not | +| ------------------------------------------------- | ------------------------------------------------ | +| Hold an in-memory replica and sync it with peers | Persist anything (no storage extension is wired) | +| Authenticate the bearer token against Graph `/me` | Read or write the file | +| Enforce read/write access per file, per user | Decide when to save | +| Stamp the authenticated identity onto awareness | Serialize the CRDT back to text | +| Reject peers running a mismatched app version | Survive a restart, or an empty room | + +Notably it never converts the CRDT back into file content. That only happens in a browser, through the app's adapter - which is why saving needs a client to be online. + +### Access control + +`onAuthenticate` runs per connection, before any document data flows: + +```mermaid +sequenceDiagram + participant C as Client + participant HP as Yjs server + participant G as Graph API + + C->>HP: connect(room, token, appVersion) + HP->>G: GET /graph/v1.0/me + G-->>HP: user identity + HP->>G: GET drives/{driveId}/items/{itemId}/permissions + G-->>HP: allowed actions + alt 401 / 403 / 404 + HP-->>C: reject - access denied + else + HP->>HP: appVersion matches the room's baseline? + Note over HP: first client into a room sets the baseline + alt mismatch + HP-->>C: reject - please reload + else write action present + HP-->>C: accept (read-write) + else read only + HP-->>C: accept (readOnly) + end + end +``` + +The version baseline is recorded only once identity and access are settled. Doing it any earlier let an unauthenticated caller name any room, claim a version nobody else runs, and lock every legitimate client out of that file until the process restarted - the entry is cleared by `onDisconnect`, which never fires for a connection that was rejected. + +The room name is `::$!`. The prefix is `collaborative.documentPrefix`, defaulting to the app's `applicationId`. The file id is `resource.id`, which is already global: a share recipient sees the same composite id as the owner, so both land in the same room. Note that this must not be `resource.remoteItemId` - `AppWrapper` fills that with the share space id, which identifies the mount point rather than the file, so every file inside a shared folder would collapse into one room. The server strips the `::` part before parsing, so the ACL probe targets the real file. The prefix exists so two editors with incompatible Y.Doc layouts (Tiptap's `Y.XmlFragment` vs CodeMirror's `Y.Text`) never share a room for the same file. + +Awareness is anti-spoofed: `beforeHandleAwareness` overwrites the `user` field on every inbound awareness state with the identity from the authenticated connection, so a client cannot present itself as someone else. + +## Inside `web` + +Ownership in one line: **`AppWrapper` owns the file and the session; the app owns the editor and the format.** + +```mermaid +flowchart TB + subgraph app["Extension - e.g. packages/web-app-text-editor"] + IDX["index.ts
AppWrapperRoute(App, { collaborative })"] + ADP["the adapter
hydrate · serialize
hasContent · reset"] + APP["App.vue
useTextEditor(ydoc, awareness)"] + end + + subgraph pkg["web-pkg"] + AW["AppWrapper.vue
load · save · etag · dirty · autosave"] + UCD["useCollaborativeDocument
Y.Doc · provider · hydration"] + UTE["useTextEditor
Tiptap + Collaboration"] + end + + YDOC[("Y.Doc
XmlFragment 'default'
Map '_oc_meta'")] + HP["Yjs server"] + DAV["WebDAV"] + + IDX -->|"collaborative: { appVersion, makeAdapter }"| AW + AW -->|"makeAdapter(), once in setup"| ADP + ADP -->|"CollaborativeAdapter"| UCD + AW --> UCD + UCD <-->|"reads + writes through the adapter"| YDOC + UCD <-->|"sync + awareness"| HP + AW -->|"slot props:
ydoc · awareness · resource
currentContent · isReadOnly"| APP + APP --> UTE + UTE <-->|"@tiptap/extension-collaboration"| YDOC + AW <-->|"GET / PUT"| DAV + + classDef appc fill:#e8f0fe,stroke:#4285f4,color:#111 + classDef pkgc fill:#e6f4ea,stroke:#34a853,color:#111 + classDef data fill:#fce8e6,stroke:#ea4335,color:#111 + classDef ext fill:#fef7e0,stroke:#f9ab00,color:#111 + class IDX,ADP,APP appc + class AW,UCD,UTE pkgc + class YDOC data + class HP,DAV ext +``` + +### Opting in + +An app turns on collaboration with one route option. + +```ts +// packages/web-app-text-editor/src/index.ts +AppWrapperRoute(TextEditor, { + applicationId: 'text-editor', + collaborative: { + appVersion: pkg.version, + makeAdapter: makeTextEditorAdapter + } +}) +``` + +### What happens when you open a file + +1. WebDAV loads the file → gets saved to `currentContent` (`AppWrapper`) +2. Session creates the Y.Doc (empty, and it exists from here on) (`useCollaborativeDocument`, invoked by `AppWrapper`) +3. `HocuspocusProvider` gets handed the Y.Doc and connects to the Yjs server and syncs (`useCollaborativeDocument`) + +From there on, first client: + +1. `hasContent(ydoc)` is `false` → this client wins the election → `adapter.hydrate` (`collabAdapter.ts`) +2. `deserialize` (md/html pass through, json `JSON.parse`, plain-text builds the ProseMirror JSON itself) (`collabAdapter.ts`) +3. Headless editor is constructed with the strategy's extensions + Collaboration bound to the existing Y.Doc - `ySyncPlugin` attaches here (`collabAdapter.ts`) +4. `setContent` builds the ProseMirror tree, guided by `contentType` and the schema (`collabAdapter.ts`) +5. `ySyncPlugin` sees that transaction and writes the tree into the Y.XmlFragment +6. Y.Doc lives locally, and syncs to the Yjs server if a provider exists + +Both lists run _after_ the version handshake and the etag drift check, which can short-circuit into a reload lock or into stale recovery before hydration is ever considered. + +Client joining afterwards: + +1. `hasContent(ydoc)` is `true` → return early, no hydration, no election, no 150 ms wait. (`useCollaborativeDocument.ts`) +2. Y.Doc lives locally, and syncs to the Yjs server if a provider exists + +### Y.Doc + +Y.Doc is the CRDT that holds the shared state. It is a tree of shared types, and the editor content lives in a `Y.XmlFragment` named `"default"`. The session also maintains a `Y.Map` named `"_oc_meta"` for coordination and metadata. + +There is no single global document. Every participant holds its own `Y.Doc`, and the server holds one too: + +```mermaid +flowchart LR + subgraph room["One room = one file, one editor app"] + direction LR + DA[("Client A
Y.Doc")] + DS[("Yjs server
Y.Doc
memory only")] + DB[("Client B
Y.Doc")] + DA <-->|"updates"| DS + DS <-->|"updates"| DB + end + + F[("File on disk
WebDAV")] + DA -.->|"hydrate: read once"| F + DA -.->|"save: serialize + PUT"| F + DB -.->|"save: serialize + PUT"| F + + classDef rep fill:#fce8e6,stroke:#ea4335,color:#111 + classDef file fill:#e6f4ea,stroke:#34a853,color:#111 + class DA,DB,DS rep + class F file +``` + +All three are convergent replicas of the same CRDT. None is authoritative - that is the point of a CRDT: updates applied in any order end up at the same state. The server's copy is not a coordinator, it is a participant that happens to always be present. + +It exists for two reasons. It gives a late joiner the room's current state in one initial sync, without any peer having to notice and re-send. And because it is a real `Y.Doc`, server-side hooks could read `_oc_meta` if a future stale probe ever needed to run server-side. Nothing does today. + +The server's replica is memory-only - no storage extension is wired up. When the last client disconnects, the room empties and that copy is gone. Reconnecting later starts from a blank server doc, which is why hydration runs again. That is a deliberate trade - see [Known limits](#known-limits). + +### The CollaborativeAdapter + +The app provides a `CollaborativeAdapter` to the session, which is how the session reads and writes the Y.Doc. The adapter is responsible for converting between the native file format and the Y.Doc's shared types. + +```ts +interface CollaborativeAdapter { + hydrate(ydoc: Y.Doc, content: string): void | Promise + serialize(ydoc: Y.Doc): string | Promise + hasContent(ydoc: Y.Doc): boolean + reset?(ydoc: Y.Doc): void +} +``` + +`makeAdapter` runs during `AppWrapper`'s setup - before the file is loaded - so it receives a reactive context (`{ resource: Ref }`) and reads it lazily. It must run in setup because content strategies call `useGettext()`, which is why `makeTextEditorAdapter` resolves its strategies eagerly and only picks between them per call. + +### Startup order + +```mermaid +sequenceDiagram + autonumber + participant AW as AppWrapper + participant DAV as WebDAV + participant S as useCollaborativeDocument + participant HP as Yjs server + participant APP as App.vue + + AW->>DAV: PROPFIND - resource info + DAV-->>AW: resource (id, etag, permissions) + AW->>DAV: GET - file body + DAV-->>AW: currentContent + OC-ETag + Note over AW,S: only now is the session enabled -
hydration seeds from currentContent + AW->>S: enabled = true + S->>HP: connect(room, token, appVersion) + HP-->>S: authenticated + S->>HP: initial sync + HP-->>S: onSynced + S->>S: version handshake · etag drift check · hydration election + Note over S: winner runs adapter.hydrate(ydoc, currentContent) + S-->>AW: isReady = true + Note over AW: loading screen drops, slot renders + AW->>APP: ydoc + awareness (non-null, hydrated) + APP->>APP: useTextEditor binds Tiptap to the Y.Doc +``` + +`AppWrapper` keeps its loading screen up until the session reports ready, so `App.vue` mounts against a Y.Doc that is already synced and hydrated. That is what lets the app declare `ydoc` and `awareness` as required props and call `useTextEditor` directly, with no placeholder of its own. + +### Hydration + +Hydration is the process of taking the file content and seeding it into the Y.Doc. It usually only happens once per room, and only if the room is empty. The first client to arrive into an empty room runs `adapter.hydrate(ydoc, currentContent)`. All other clients skip hydration and wait for the Yjs server to sync them up. A state recovery may re-run hydration if the room turns out to be stale, i.e. `_oc_meta.etag` no longer matches the etag the joining client just fetched. + +Two clients can arrive into an empty room at the same moment, and only one may seed it - otherwise the content lands twice. The elected client is the one with the lowest Yjs `clientID`, after a 150 ms pause to let peers announce themselves. The winner sets `_oc_meta.hydrated` before it seeds, so peers can react to the incoming content rather than merge with it. + +### Stale recovery + +When a joining client finds that `_oc_meta.etag` no longer matches the etag it just fetched, the file changed outside the room. It stamps `nativeEtag`, claims the job via `recoveryClientId` and raises `isStale`. Every peer's meta observer fires, but only the elected one gets through: recovery wipes the room and re-seeds it, and the only peer holding the body behind `nativeEtag` is the one that just fetched the file. Any other peer would publish the copy it opened with - or its own last serialization - and then stamp the fresh etag onto it, so the next save would overwrite the external writer with a matching `If-Match` and no conflict. + +The elected peer captures that body at detection time rather than reading `currentContent` when recovery runs. By then the room has synced its own state into the Y.Doc and the debounced serialize has reported it straight back into `currentContent`. + +`recoveryClientId` is last-write-wins, so if several clients detect the same drift at once, exactly one of them survives convergence. A peer that joins while `isStale` is already up offers itself the same way, provided its etag matches `nativeEtag` - the observer only fires on change, so without that the room would stay stuck if the elected peer navigated away mid-recovery. + +Reset lands before hydrate, so a throw in between leaves every peer looking at an empty document. That path keeps `isStale` up for the next joiner to retry and locks the session, which is what stops the autosave from writing the emptiness to disk. + +### `_oc_meta` + +A `Y.Map` alongside the editor content, used for coordination the editor never sees: + +| Key | Written by | Meaning | +| ------------------ | ------------------- | --------------------------------------------- | +| `etag` | whoever saved last | the etag the room believes is on disk | +| `lastSavedAt` | whoever saved last | fan-out trigger for a peer save | +| `savedStateVector` | whoever saved last | what that peer's doc held when it wrote | +| `appVersion` | first peer in | schema version the room is running | +| `isStale` | any writer | the file changed outside this room; rehydrate | +| `nativeEtag` | writer that noticed | the etag recovery should settle on | +| `recoveryClientId` | writer that noticed | which peer is elected to re-seed the room | +| `hydrated` | the seeding peer | someone is seeding the room right now | + +Every key lives in the shared Y.Doc, so a read-only peer's writes to it are +rejected by the Yjs server along with everything else. Staleness noticed by a +read-only peer alone therefore never reaches the room. + +### Saving + +Saving is unchanged from single-user editing: `AppWrapper` PUTs over WebDAV with `If-Match`. Collaboration only changes where the content comes from and how conflicts resolve. + +```mermaid +sequenceDiagram + participant U as User A + participant SA as Session A + participant AWA as AppWrapper A + participant HP as Yjs server + participant SB as Session B + participant AWB as AppWrapper B + participant DAV as WebDAV + + U->>SA: types + SA->>HP: CRDT update + HP->>SB: CRDT update + Note over SA: 300 ms after typing stops + SA->>SA: adapter.serialize(ydoc) + SA->>AWA: onContentChange + Note over AWA: currentContent updated,
isDirty flips to true - no PUT yet + Note over AWA: later: Ctrl+S or the 120 s autosave + AWA->>DAV: PUT If-Match: etag + DAV-->>AWA: new etag + AWA->>SA: resource.etag changed + SA->>HP: _oc_meta.etag + savedStateVector + lastSavedAt + HP->>SB: meta update + SB->>SB: did that save cover my edits? + SB->>AWB: onServerContentChange + onEtagChange + Note over AWB: isDirty drops to false,
next If-Match is already correct +``` + +A peer's save only makes B clean if it actually contains B's work. What A wrote is what _A's_ doc serialized to, so `savedStateVector` carries A's Yjs state vector at write time and B compares its own client clock against it. Covered means B contributed nothing A was missing, so B has nothing left to save. Not covered means B typed something A's PUT never saw, and B stays dirty - dropping the flag there would also unregister `beforeunload` and wave the route-leave guard through, losing the edit with the tab. The etag is mirrored either way: it is factual, and it keeps B's next `If-Match` correct. + +Only B's own client id is compared. A third peer's unsaved operations are that peer's dirty state to track. + +The 300 ms debounce only refreshes `currentContent`, which is what drives `isDirty`. It never triggers a PUT on its own. The actual write comes from a manual save (Ctrl+S) or the autosave timer (default 120 s), and both go through the same path. If a PUT comes back 409/412, what happens depends on whether a realtime session is actually connected. With one, `AppWrapper` refetches, compares, and retries once with the fresh etag before falling back to a conflict message - the local Y.Doc already contains the peer's edits, so the retry publishes the merged state. Without one - a plain editor, or a deployment with no `yjsServerUrl` - there is nothing to merge, so the conflict dialog comes up straight away, exactly as it did before collaboration existed. Retrying there would silently overwrite whoever else wrote the file. + +### Local mode + +When `options.yjsServerUrl` is unset the session still creates a `Y.Doc` and a standalone `Awareness`, skips the provider, and hydrates immediately. The Y.Doc binding is the same in both modes, so adapters and editor extensions need no branch. Two things do differ: no peers ever appear, and `useTextEditor` re-reads `yjsServerUrl` itself to decide whether to drop the source-mode action. That is what "collaboration disabled" means here - the editor works as it always did, it just never syncs. + +A session configured for collaboration ends up here too when the server does not answer. A provider that cannot reach its server emits neither `onSynced` nor `onAuthenticationFailed` - it just keeps retrying - so nothing would ever release the loading gate. After `CONNECT_TIMEOUT_MS` the session gives up, disconnects the provider, hydrates locally and surfaces an error saying changes will not be shared. The file stays editable and saveable; only syncing is gone, and a reload is what retries. The provider is disconnected rather than destroyed, because the editor binds to its awareness; and it is not left retrying, because a late connect would merge the locally hydrated copy into a room that may already hold the same content and duplicate the document for everyone. + +### File map + +| Path | Role | +| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| `packages/web-pkg/src/composables/collaborative/useCollaborativeDocument.ts` | the session: Y.Doc, provider, hydration, staleness, version gate, etag mirror | +| `packages/web-pkg/src/composables/collaborative/types.ts` | `CollaborativeAdapter` | +| `packages/web-pkg/src/components/AppTemplates/AppWrapper.vue` | owns the session, the save loop and the loading gate | +| `packages/web-pkg/src/components/AppTemplates/types.ts` | `CollaborativeOptions`, `CollaborativeAdapterContext`, slot args | +| `packages/web-pkg/src/editor/collabAdapter.ts` | `makeTiptapCollabAdapter` - any strategy to a Y.Doc | +| `packages/web-pkg/src/editor/composables/useTextEditor.ts` | binds Tiptap to a Y.Doc and renders peer carets | +| `packages/web-app-text-editor/src/collab.ts` | the text editor's adapter and content-type detection | +| `services/realtime/src/server.ts` | the Yjs server (Hocuspocus) | + +--- + +## Known limits + +These are open items, not bugs to be surprised by. + +**Durability depends on an open browser.** The Yjs server holds no state and does not save. If every client closes between autosaves, edits made since the last save are lost with the room. A server-side flush would require `serialize` to run in Node, which the Tiptap-based adapter cannot do today. + +**Every peer autosaves.** A remote edit marks _your_ `AppWrapper` dirty, so with N clients open the same document gets saved N times per autosave interval. There is election for hydration but none for saving. + +**Peer edits arm your unsaved-changes guard.** Same root cause: remote CRDT updates flow into `currentContent`, so `beforeunload` and the unsaved-changes modal fire for edits you did not make. Writers only - `isDirty` is hard-wired to `false` for a read-only client, which has nothing to save. + +**Hydration election can race.** The 150 ms awareness-settle window is heuristic. If awareness has not propagated in time, two peers can both elect themselves and hydrate, duplicating content. Server-side hydration would remove the whole class. + +**`_oc_meta` is writable by any client with write access.** A buggy or hostile client can set `appVersion` or `isStale` and lock or reset every peer in the room. The room's control plane has no server authority beyond the read-only gate. + +**`appVersion` is `0.0.0`.** `web-app-text-editor` is a private package with a placeholder version, so the version gate never actually fires for it. + +**Source mode is disabled in collab.** It swaps the ProseMirror view for a plain textarea, which has no Y.Doc binding, so `useTextEditor` drops the `source-mode` action whenever a realtime session is active. Marked as a `FIXME`. + +**Connection state is invisible.** The session exposes a `status` ref (`connecting` / `connected` / `disconnected` / `local`). `AppWrapper` reads it to decide whether a save conflict can be reconciled, but never shows it. A websocket that drops _after_ a successful sync produces no indicator: the user keeps typing into a Y.Doc that no longer reaches anyone. Only a connect that never succeeds in the first place is caught, by the timeout above. + +**A live session resolves save conflicts in favour of the room.** While connected, a 409/412 is retried with the fresh etag on the assumption that it came from a peer. It usually did, but an external writer - a desktop sync client, say - is overwritten without a prompt. Proving which one it was would mean matching the fetched etag against `_oc_meta.etag`, and that race is roughly a coin flip, so it would turn ordinary peer saves into spurious conflict dialogs. + +**`_oc_meta.hydrated` is never cleared.** Stale recovery deletes `isStale`, `nativeEtag` and `recoveryClientId` but leaves `hydrated` set. Harmless today, since the recovered room really is seeded, but it means the flag tracks "this room was ever seeded" rather than "a peer is seeding right now". + +**Stale recovery needs someone who holds the fresh body.** Only a peer whose fetched etag matches `nativeEtag` may re-seed the room. If that peer leaves before finishing, the room stays flagged until another client opens the file and picks the job up. Peers already in the room keep editing a document that no longer matches disk in the meantime. + +**Bundle weight.** `AppWrapper` imports the session directly, so `yjs`, `y-protocols` and `@hocuspocus/provider` land in web-pkg's main entry - loaded even by users who never open an editor. Moving the session behind a `defineAsyncComponent` would fix it at the cost of some indirection. diff --git a/docker-compose.yml b/docker-compose.yml index cce8db887d..df55b61f50 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -83,6 +83,8 @@ x-opencloud-server: &opencloud-service OCM_OCM_SHARE_PROVIDER_INSECURE: 'true' OCM_OCM_STORAGE_PROVIDER_INSECURE: 'true' + # Yjs collaboration + WEB_OPTION_YJS_SERVER_URL: 'wss://host.docker.internal:9200/realtime' extra_hosts: - host.docker.internal:${DOCKER_HOST:-host-gateway} - oc.opencloud.test:${DOCKER_HOST:-host-gateway} @@ -425,6 +427,33 @@ services: depends_on: - stalwart + realtime: + build: + # Repository root, the pnpm lockfile is needed for the install + context: . + dockerfile: services/realtime/Dockerfile + extra_hosts: + - host.docker.internal:${DOCKER_HOST:-host-gateway} + environment: + PORT: '1234' + OPENCLOUD_URL: https://host.docker.internal:9200 + # Dev only: accept the self-signed cert from OC's Traefik + NODE_TLS_REJECT_UNAUTHORIZED: '0' + # Dev only: allows the integration tests to bypass real OIDC tokens + NODE_ENV: development + DEV_FAKE_TOKEN: dev-integration-token + volumes: + # Dev only: edit the server without rebuilding the image + - ./services/realtime/src:/app/src:ro + networks: + # On the traefik network so OC (which is also on it) can reach the + # service by name. Browser-facing routing is not done at the Traefik + # layer - OC's reverse proxy forwards /realtime via + # `additional_policies` in opencloud/proxy.yaml. This keeps the routing + # model identical to what CI uses (CI has no Traefik). + - traefik + restart: unless-stopped + volumes: uploads: opencloud-config: diff --git a/package.json b/package.json index f73355564f..21f330bddf 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,12 @@ "scripts": { "build": "pnpm vite build", "build:w": "pnpm build --watch", - "lint": "eslint vite.config.ts '{packages,tests}/**/*.{js,ts,vue}' --color", + "lint": "eslint vite.config.ts '{packages,services,tests}/**/*.{js,ts,vue}' --color", "format:check": "prettier . --config packages/prettier-config/index.js --check", "format:write": "prettier . --config packages/prettier-config/index.js --write", "serve": "SERVER=true pnpm build:w", "test:unit": "NODE_OPTIONS=--unhandled-rejections=throw vitest --config ./tests/unit/config/vitest.config.ts", - "licenses:check": "license-checker-rseidelsohn --summary --relativeLicensePath --onlyAllow 'Python-2.0;Apache*;Apache License, Version 2.0;Apache-2.0;Apache 2.0;Artistic-2.0;BSD;BSD-3-Clause;CC-BY-3.0;CC-BY-4.0;CC0-1.0;ISC;MIT;MPL-2.0;Public Domain;Unicode-TOU;Unlicense;WTFPL;BlueOak-1.0.0' --excludePackages '@opencloud-eu/eslint-config;@opencloud-eu/prettier-config;@opencloud-eu/tsconfig;@opencloud-eu/web-client;@opencloud-eu/web-pkg;external;web-app-files;text-editor;preview;web-app-ocm;@opencloud-eu/design-system;pdf-viewer;web-app-search;admin-settings;webfinger;web-runtime;@opencloud-eu/web-test-helpers'", + "licenses:check": "license-checker-rseidelsohn --summary --relativeLicensePath --onlyAllow 'Python-2.0;Apache*;Apache License, Version 2.0;Apache-2.0;Apache 2.0;Artistic-2.0;BSD;BSD-3-Clause;CC-BY-3.0;CC-BY-4.0;CC0-1.0;ISC;MIT;MPL-2.0;Public Domain;Unicode-TOU;Unlicense;WTFPL;BlueOak-1.0.0' --excludePackages '@opencloud-eu/eslint-config;@opencloud-eu/prettier-config;@opencloud-eu/tsconfig;@opencloud-eu/web-client;@opencloud-eu/web-pkg;external;web-app-files;text-editor;preview;web-app-ocm;@opencloud-eu/design-system;pdf-viewer;web-app-search;admin-settings;webfinger;web-runtime;web-realtime-server;@opencloud-eu/web-test-helpers'", "licenses:csv": "license-checker-rseidelsohn --relativeLicensePath --csv --out ./third-party-licenses/third-party-licenses.csv", "licenses:save": "license-checker-rseidelsohn --relativeLicensePath --out /dev/null --files ./third-party-licenses/third-party-licenses", "vite": "vite", diff --git a/packages/web-app-epub-reader/src/App.vue b/packages/web-app-epub-reader/src/App.vue index b6dc16db51..ce5782691b 100644 --- a/packages/web-app-epub-reader/src/App.vue +++ b/packages/web-app-epub-reader/src/App.vue @@ -93,13 +93,13 @@ diff --git a/packages/web-app-text-editor/src/collab.ts b/packages/web-app-text-editor/src/collab.ts new file mode 100644 index 0000000000..2bbd3144e9 --- /dev/null +++ b/packages/web-app-text-editor/src/collab.ts @@ -0,0 +1,54 @@ +import { ref, unref } from 'vue' +import type { Resource } from '@opencloud-eu/web-client' +import type { CollaborativeAdapter, CollaborativeAdapterContext } from '@opencloud-eu/web-pkg' +import { + makeTiptapCollabAdapter, + useContentStrategy, + type ContentType, + type ContentTypeStrategy, + type TextEditorLinkPanelRequest, + type TextEditorState +} from '@opencloud-eu/web-pkg/editor' + +/** The content types this app can produce. A subset of {@link ContentType}. */ +export type TextEditorContentType = Extract + +export function detectContentType(resource: Resource): TextEditorContentType { + const extension = resource?.extension?.toLowerCase() + const mimeType = resource?.mimeType?.toLowerCase() + if (extension === 'md' || extension === 'markdown' || mimeType === 'text/markdown') { + return 'markdown' + } + + return 'plain-text' +} + +/** + * Builds the Y.Doc bridge the AppWrapper's collaborative session runs on. + * + * Called during the wrapper's setup, before the file is loaded, so the + * content type is resolved lazily, per call, from the resource ref. The + * strategies themselves must be built eagerly: they call `useGettext()`, which + * only works while a setup context is active. + */ +export function makeTextEditorAdapter({ + resource +}: CollaborativeAdapterContext): CollaborativeAdapter { + const { resolveStrategy } = useContentStrategy() + + // The adapter needs its own editor state for the headless fallback path; the + // mounted editor resolves its own inside `useTextEditor`. + const state: TextEditorState = { + sourceMode: ref(false), + linkPanel: ref(null), + editorZoom: ref(100), + currentResource: resource + } + + const strategies: Record = { + 'plain-text': resolveStrategy('plain-text', state), + markdown: resolveStrategy('markdown', state) + } + + return makeTiptapCollabAdapter(() => strategies[detectContentType(unref(resource))]) +} diff --git a/packages/web-app-text-editor/src/index.ts b/packages/web-app-text-editor/src/index.ts index 053d941fd2..3a5f52be89 100644 --- a/packages/web-app-text-editor/src/index.ts +++ b/packages/web-app-text-editor/src/index.ts @@ -13,6 +13,8 @@ import { } from '@opencloud-eu/web-pkg' import { computed } from 'vue' import { urlJoin } from '@opencloud-eu/web-client' +import { makeTextEditorAdapter } from './collab' +import pkg from '../package.json' export default defineWebApplication({ setup({ applicationConfig }) { @@ -200,7 +202,8 @@ export default defineWebApplication({ { path: '/:driveAliasAndItem(.*)?', component: AppWrapperRoute(TextEditor, { - applicationId: appId + applicationId: appId, + collaborative: { appVersion: pkg.version, makeAdapter: makeTextEditorAdapter } }), name: 'text-editor', meta: { diff --git a/packages/web-app-text-editor/tests/unit/app.spec.ts b/packages/web-app-text-editor/tests/unit/app.spec.ts index 638d1d6b44..a33e2931c4 100644 --- a/packages/web-app-text-editor/tests/unit/app.spec.ts +++ b/packages/web-app-text-editor/tests/unit/app.spec.ts @@ -1,26 +1,94 @@ import { PartialComponentProps, defaultPlugins, mount } from '@opencloud-eu/web-test-helpers' import { mock } from 'vitest-mock-extended' +import { defineComponent, shallowRef, toRaw } from 'vue' +import * as Y from 'yjs' +import { Awareness } from 'y-protocols/awareness' +import type { Editor } from '@tiptap/vue-3' import type { Resource } from '@opencloud-eu/web-client' +import type { TextEditorOptions } from '@opencloud-eu/web-pkg/editor' import App from '../../src/App.vue' -vi.mock('@opencloud-eu/web-pkg') -vi.mock('@opencloud-eu/web-pkg/editor') +// The editor itself is covered by web-pkg. What App.vue owns is the decision +// of *which* options the editor gets, so we capture those instead of mounting +// a real ProseMirror stack. +const useTextEditor = vi.hoisted(() => vi.fn()) + +vi.mock('@opencloud-eu/web-pkg/editor', async (importOriginal) => { + const original = await importOriginal>() + return { + ...original, + useTextEditor, + TextEditorProvider: defineComponent({ + props: { editor: { type: Object, default: null } }, + template: '
' + }), + TextEditorContent: defineComponent({ template: '
' }), + TextEditorToolbar: defineComponent({ template: '
' }) + } +}) + +beforeEach(() => { + useTextEditor.mockReset() + useTextEditor.mockReturnValue({ editor: shallowRef(null) }) +}) + +function lastOptions(): TextEditorOptions { + return useTextEditor.mock.calls.at(-1)[0] +} describe('Text editor app', () => { - it('shows the editor', () => { - const { wrapper } = getWrapper() - expect(wrapper.find('.oc-text-editor').exists()).toBeTruthy() + it('binds the editor to the Y.Doc and awareness it was handed', () => { + const ydoc = new Y.Doc() + const awareness = new Awareness(ydoc) + getWrapper({ ydoc, awareness }) + + // toRaw because vue-test-utils wraps mounted props in `reactive()`. The + // real AppWrapper hands these over from a shallowRef, so they stay raw. + expect(toRaw(lastOptions().ydoc)).toBe(ydoc) + expect(toRaw(lastOptions().awareness)).toBe(awareness) + }) + + it('detects the content type from the resource', () => { + getWrapper({ resource: mock({ extension: 'md', mimeType: 'text/markdown' }) }) + expect(lastOptions().contentType).toBe('markdown') + + getWrapper({ resource: mock({ extension: 'ts', mimeType: 'text/plain' }) }) + expect(lastOptions().contentType).toBe('plain-text') + }) + + it('only sets a placeholder for editable markdown', () => { + getWrapper({ resource: mock({ extension: 'md', mimeType: 'text/markdown' }) }) + expect(lastOptions().placeholder).toBeTruthy() + + getWrapper({ resource: mock({ extension: 'txt', mimeType: 'text/plain' }) }) + expect(lastOptions().placeholder).toBeUndefined() + + getWrapper({ + isReadOnly: true, + resource: mock({ extension: 'md', mimeType: 'text/markdown' }) + }) + expect(lastOptions().placeholder).toBeUndefined() + }) + + it('shows the toolbar when editable and hides it when read-only', () => { + expect(getWrapper().wrapper.find('.text-editor-toolbar').exists()).toBe(true) + expect(getWrapper({ isReadOnly: true }).wrapper.find('.text-editor-toolbar').exists()).toBe( + false + ) }) }) function getWrapper(props: PartialComponentProps = {}) { + const ydoc = (props.ydoc as Y.Doc) ?? new Y.Doc() return { wrapper: mount(App, { props: { currentContent: '', isReadOnly: false, resource: mock({ extension: 'txt', mimeType: 'text/plain' }), - ...props + awareness: new Awareness(ydoc), + ...props, + ydoc }, global: { plugins: defaultPlugins() } }) diff --git a/packages/web-app-text-editor/tests/unit/collab.spec.ts b/packages/web-app-text-editor/tests/unit/collab.spec.ts new file mode 100644 index 0000000000..d5ebcb7b92 --- /dev/null +++ b/packages/web-app-text-editor/tests/unit/collab.spec.ts @@ -0,0 +1,59 @@ +import { getComposableWrapper } from '@opencloud-eu/web-test-helpers' +import { mock } from 'vitest-mock-extended' +import { ref } from 'vue' +import * as Y from 'yjs' +import type { Resource } from '@opencloud-eu/web-client' +import type { CollaborativeAdapter } from '@opencloud-eu/web-pkg' +import { detectContentType, makeTextEditorAdapter } from '../../src/collab' + +describe('detectContentType', () => { + it.each([ + ['md', 'text/plain', 'markdown'], + ['markdown', 'text/plain', 'markdown'], + ['txt', 'text/markdown', 'markdown'], + ['txt', 'text/plain', 'plain-text'], + ['ts', 'text/plain', 'plain-text'] + ])('maps extension "%s" / mime "%s" to %s', (extension, mimeType, expected) => { + expect(detectContentType(mock({ extension, mimeType }))).toBe(expected) + }) +}) + +describe('makeTextEditorAdapter', () => { + function buildAdapter(resource: Resource) { + let adapter: CollaborativeAdapter + const wrapper = getComposableWrapper(() => { + adapter = makeTextEditorAdapter({ resource: ref(resource) }) + }) + return { + wrapper, + get adapter() { + return adapter + } + } + } + + it('hydrates markdown into the shared fragment and serializes it back', () => { + const { adapter } = buildAdapter(mock({ extension: 'md', mimeType: 'text/markdown' })) + const ydoc = new Y.Doc() + + expect(adapter.hasContent(ydoc)).toBe(false) + adapter.hydrate(ydoc, '# Title') + expect(adapter.hasContent(ydoc)).toBe(true) + expect(adapter.serialize(ydoc)).toContain('# Title') + }) + + it('resets the shared fragment', () => { + const { adapter } = buildAdapter(mock({ extension: 'md', mimeType: 'text/markdown' })) + const ydoc = new Y.Doc() + adapter.hydrate(ydoc, 'content') + adapter.reset!(ydoc) + expect(adapter.hasContent(ydoc)).toBe(false) + }) + + it('is a no-op when hydrating with empty content', () => { + const { adapter } = buildAdapter(mock({ extension: 'txt', mimeType: 'text/plain' })) + const ydoc = new Y.Doc() + adapter.hydrate(ydoc, '') + expect(adapter.hasContent(ydoc)).toBe(false) + }) +}) diff --git a/packages/web-pkg/package.json b/packages/web-pkg/package.json index bfb9480089..ab71c68969 100644 --- a/packages/web-pkg/package.json +++ b/packages/web-pkg/package.json @@ -49,12 +49,14 @@ "dependencies": { "@casl/ability": "^7.0.0", "@casl/vue": "^3.0.0", + "@hocuspocus/provider": "^4.0.0", "@microsoft/fetch-event-source": "^2.0.1", "@opencloud-eu/design-system": "workspace:^", "@opencloud-eu/web-client": "workspace:^", "@sentry/vue": "^10.46.0", "@tiptap/core": "^3.28.0", "@tiptap/extension-bubble-menu": "^3.29.2", + "@tiptap/extension-collaboration": "^3.28.0", "@tiptap/extension-document": "^3.28.0", "@tiptap/extension-drag-handle": "^3.28.0", "@tiptap/extension-drag-handle-vue-3": "^3.28.0", @@ -78,6 +80,7 @@ "@tiptap/starter-kit": "^3.28.0", "@tiptap/suggestion": "^3.28.0", "@tiptap/vue-3": "^3.28.0", + "@tiptap/y-tiptap": "^3.0.0", "@uppy/core": "^5.2.0", "@uppy/tus": "^5.1.1", "@uppy/utils": "^7.2.0", @@ -99,17 +102,21 @@ "pinia": "^4.0.0", "prosemirror-transform": "^1.12.0", "qs": "^6.15.0", + "semver": "^7.8.0", "uuid": "^14.0.0", "vue-concurrency": "^5.0.3", "vue-inline-svg": "^4.0.1", "vue-router": "^5.0.4", "vue3-gettext": "4.0.1", + "y-protocols": "^1.0.7", + "yjs": "^13.6.0", "zod": "^4.3.6" }, "devDependencies": { "@opencloud-eu/web-test-helpers": "workspace:^", "@types/lodash-es": "4.17.12", "@types/node": "^25.5.0", + "@types/semver": "^7.7.0", "@vitest/web-worker": "^4.1.2", "vite-plugin-node-polyfills": "0.28.0" } diff --git a/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue b/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue index 80b8bd5178..0e2f3d7a04 100644 --- a/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue +++ b/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue @@ -1,7 +1,7 @@