Skip to content

Commit 55cba0d

Browse files
authored
feat(collab): real-time multi-user collaboration MVP (#307) (#329)
* feat(collab): real-time multi-user collaboration MVP (#307) Add live-synced sessions: several people open the same map and see each other's layer/style/view edits in real time, with presence cursors and viewport indicators. Targets classrooms, workshops, and small teams. Architecture (see docs/collaboration.md): - A new Cloudflare Worker + Durable Object relay (workers/collab) holds the latest project snapshot and fans WebSocket messages out to all peers. - The frontend adapter mirrors useEmbedBridge: subscribe to the store -> debounce -> serializeProject -> broadcast; inbound snapshots are applied via loadProject with a shared lastAppliedContent cache to suppress echoes. - Whole-snapshot, last-write-wins sync (no CRDT yet); reuses the existing serializeProject/parseProject wire format. - Presence (cursors + viewport rectangles) rides a separate channel and renders via MapLibre Markers + a dedicated GeoJSON line layer. - Anonymous identity: host shares a code/link; joiners pick name + color. Host sets view-only vs co-edit, enforced server-side via a host token. Ephemeral collaboration state lives in a new core store slice, excluded from the project file and from undo history. Gated entirely behind VITE_GEOLIBRE_COLLAB_URL; the hook is inert and the UI hidden when unset. Adds workers/collab to test:worker, unit tests for the client/protocol, and the wss host to the Tauri CSP. * ci(collab): add Cloudflare Workers deploy workflow + deploy docs (#307) Mirrors deploy-viewer.yml: deploys workers/collab on push to main (or workflow_dispatch) using the existing CLOUDFLARE_API_TOKEN/ACCOUNT_ID secrets. Documents the deploy + VITE_GEOLIBRE_COLLAB_URL wiring in docs/collaboration.md. * ci(pages): wire VITE_GEOLIBRE_COLLAB_URL into the web demo build (#307) Bakes the public collab relay URL (wss://collab.geolibre.app) into the deployed web app so live collaboration is enabled there. Not a secret — it ships in the client bundle. Requires the workers/collab relay to be deployed for connections to succeed. * Address Copilot/Claude/CodeRabbit review feedback (#329) Security - RemoteCursorsOverlay: build the cursor SVG with DOM/createElementNS instead of innerHTML and set color via attribute/style property, closing the XSS via a participant-controlled `color`; also update the arrow fill on color change. - session.ts: validate `color` to a hex value (fallback #888888) and assign `clientId` server-side (ignore the client value) so a participant can't spoof another's identity/presence. Worker - index.ts: retry session-code allocation on the rare collision so a new host is never silently downgraded to a guest. - session.ts: drop the unnecessary `as never` on presence `view`; stop double-parsing the snapshot (use the already-parsed message) and size-check with accurate UTF-8 byte length via TextEncoder; defensive displayName. Client - collab-client: use injectable Math.random for reconnect jitter (deterministic per-attempt jitter didn't actually spread clients); accept IPv6 `[::1]` loopback; warn on an unexpected session mode; document indefinite-reconnect behavior. - useCollaboration: reset the collaboration slice on unmount; localize the "Guest" presence fallback. - CollaborateDialog: show localized connect/copy errors (raw error to console). Other - store: freeze DEFAULT_COLLABORATION_STATE (matches DEFAULT_LEGEND_CONFIG). - tauri CSP: allow http://localhost:* for the local create-session POST. - docs: fix resolveCollabBaseUrl name, correct the Cloudflare permission to "Workers Scripts Write" (DO needs no separate scope), note the unauthenticated wildcard-CORS create endpoint, and reflect server-assigned clientId. * Address Claude review feedback: fix antimeridian viewport polygon (#329) The presence viewport rectangle wound the long way around the globe when a participant's bbox crossed the antimeridian (east < west). Unwrap east past 180° so it stays the narrow actual viewport. * Address Claude review feedback: connect-failure handling + error cleanup (#329) - useCollaboration/collab-client: a disconnect before the first successful join (bad session code, unreachable relay) is now fatal — stop the reconnect loop and surface a localized error instead of spinning forever. CollabConnection skips the scheduled retry when onClose() calls close(). The dialog's inactive panel now shows store-side connect errors (which arrive after the join() call already resolved, so its catch block never ran). - session.ts: webSocketError is a no-op (Cloudflare fires webSocketClose after it, so cleanup happens once there instead of double-broadcasting). * Address review feedback: worker snapshot/join hardening (#329) - session.ts: reuse a module-level TextEncoder instead of allocating per frame; derive `rev` purely from stored state (never trust the client's counter); guard a non-string `displayName` before slice; exclude the joiner from the participants broadcast since `welcome` already carries the current list. * docs(collab): note desktop CSP must be updated for a self-hosted relay (#329) * Address review feedback: close-handler & guards (#329) - session.ts: during webSocketClose the closing socket can still be in getWebSockets(), so exclude it from both the participants list (new participants(except) param) and the empty-session check — otherwise the leaver lingers and the cleanup alarm never fires when the last socket goes. - useCollaboration: gate canEdit() on isActive so a debounced snapshot can't fire before the session is joined; guard connect() on a null baseUrl instead of a non-null assertion. * fix(collab): stop snapshot echo loop + auto-join from share link (#307) Two issues found in local testing: 1. "Map failed to render" on the host after adding/removing data. Root cause: applyRemoteSnapshot cached the dedupe key as serialize(merged) — the PRE-normalization input — while the store subscription fires after loadProject normalizes the project (style dedup, defaults, reordering). The post-load snapshot never matched the cache, so the receiver re-broadcast, the sender re-applied, and the loop hammered the map with loadProject reconciliations until a render threw. Now cache the POST-normalization serialize(buildProjectSnapshot()) like useEmbedBridge does. Also harden RemoteCursorsOverlay: gate the viewport source/layer on isStyleLoaded() and wrap rendering in try/catch so presence updates during a style mutation can never trip the Map error boundary. 2. Share link (?collab=CODE) didn't start joining. Auto-open the Collaborate dialog (already prefilled with the code) on load when the param is present, so the recipient only enters a name and clicks Join. * docs(collab): add local end-to-end testing steps (#307) * fix(collab): stop "Map failed to render" on collaborators (#307) Joiners crashed after host edits because applyRemoteSnapshot used loadProject for every incoming snapshot, and loadProject bumps projectGeneration — which re-runs DesktopShell's heavy plugin/native-layer restoration effect (restoreProjectState/restoreThreeDTilesLayers/restoreDeckViz/…) on every remote edit, racing the map into a render failure. Now loadProject runs only once, for the welcome bootstrap (so a late joiner still gets full native/plugin-layer restoration). Subsequent incremental remote snapshots apply the project slice directly via applyProjectToStore + setState, without bumping projectGeneration: MapCanvas reconciles from the changed layers/basemap refs and store-subscribing plugins rebuild on their own. clearHistory still keeps remote edits out of the local undo stack. * Address review feedback + QR join + color-swatch UX (#329) Worker hardening: - handleJoin tolerates a corrupt stored snapshot (parse defensively) instead of throwing and locking every participant out of the session. - Treat session "already initialized" as a present value (existing !== undefined), not a truthy one. - Ignore a duplicate join on an already-joined socket (it would orphan the old presence entry under a fresh clientId). - Validate cursor/view before storing/forwarding: reject non-finite coordinates and strip hostile extra fields. - Bootstrap late joiners with existing participants' presence in `welcome`, so cursors/viewports show immediately rather than only after the next move. Client: - connect() now resolves only on the `welcome` (and rejects on a fatal connect failure), so start()/join() and the dialog's busy spinner reflect the real join state and a fast double-click can't tear down the attempt. - CollaborateDialog: human-readable color-swatch aria-labels; default-color fallback; strip the ?collab code from the URL after reading it. New / UX (requested): - Show a QR code of the share link in an active session (qrcode.react) so collaborators can join by scanning. - Selection on a color swatch is now an outer ring instead of a border, so the selected swatch no longer looks smaller than the others. * Address review feedback: too-large send loop + overlay catch logging (#329) - useCollaboration: pause snapshot sending after a `too-large` error so an over-size project doesn't re-fail on every keystroke; reset on reconnect. - RemoteCursorsOverlay: log unexpected presence-render failures in dev instead of swallowing them entirely (transient style-mutation errors still retry). * fix(collab): stop pan-triggered snapshot churn + add Follow-host mode (#307) Recurring "Map failed to render" on collaborators: the snapshot-change check still included mapView, so the host broadcast a full-project snapshot on every pan/zoom, and each one made every collaborator re-run applyProjectToStore + setState (replacing all layers) and reconcile the map — intermittently racing into a render failure under rapid panning. Since each participant keeps its own camera, broadcasting on camera moves had no visible effect anyway. mapView is now excluded from the change check (camera flows through presence only), and the RemoteCursorsOverlay's map mutations are fully wrapped so presence rendering can never trip the Map error boundary. Feature (requested): opt-in "Follow host's view" toggle for non-host participants. When on, the participant's camera mirrors the host's viewport (applied from the host's presence on each move, and immediately from the last known view when enabled). Off by default, so cameras stay independent and don't fight in co-edit. * fix(collab): show the scan-to-join QR on the host only (#307) Only the host invites others, so the QR code is gated on isHost.
1 parent 6f3f4ad commit 55cba0d

28 files changed

Lines changed: 3004 additions & 27 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Deploy collab
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- "workers/collab/**"
8+
- ".github/workflows/deploy-collab.yml"
9+
workflow_dispatch:
10+
11+
permissions:
12+
contents: read
13+
14+
concurrency:
15+
group: deploy-collab-${{ github.ref }}
16+
cancel-in-progress: true
17+
18+
jobs:
19+
deploy:
20+
name: Deploy collaboration relay to Cloudflare Workers
21+
runs-on: ubuntu-latest
22+
steps:
23+
- name: Checkout repository
24+
uses: actions/checkout@v6
25+
26+
- name: Deploy to Cloudflare Workers
27+
uses: cloudflare/wrangler-action@v3
28+
with:
29+
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
30+
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
31+
workingDirectory: workers/collab

.github/workflows/pages.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ jobs:
4949
env:
5050
GEOLIBRE_APP_BASE: ./
5151
VITE_GEE_OAUTH_CLIENT_ID: ${{ secrets.VITE_GEE_OAUTH_CLIENT_ID }}
52+
# Public relay URL (baked into the client bundle), not a secret. Lights
53+
# up live collaboration; requires the workers/collab relay to be
54+
# deployed (see docs/collaboration.md).
55+
VITE_GEOLIBRE_COLLAB_URL: wss://collab.geolibre.app
5256

5357
- name: Build documentation
5458
run: mkdocs build --strict --site-dir site

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,6 @@ private/
3636
# not committed to the repo, to save space.
3737
docs/assets/screenshots/
3838
python/examples/my-map.geolibre.json
39+
40+
# Cloudflare Workers local dev state (wrangler dev / miniflare)
41+
.wrangler/

apps/geolibre-desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"maplibre-gl-swipe": "^0.7.1",
6464
"maplibre-gl-time-slider": "^1.0.3",
6565
"maplibre-gl-vector": "^0.3.0",
66+
"qrcode.react": "^4.2.0",
6667
"react": "^19.2.7",
6768
"react-dom": "^19.2.7",
6869
"react-i18next": "^16.6.6",

apps/geolibre-desktop/src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
}
2222
],
2323
"security": {
24-
"csp": "default-src 'self'; connect-src 'self' https: http://127.0.0.1:*; img-src 'self' data: blob: https:; media-src 'self' blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' blob: 'unsafe-eval' 'wasm-unsafe-eval' https://cdn.jsdelivr.net/npm/ https://cdn.jsdelivr.net/pyodide/ https://accounts.google.com; child-src 'self' https://accounts.google.com https://www.google.com; frame-src 'self' https://accounts.google.com https://www.google.com; worker-src blob: 'self'",
24+
"csp": "default-src 'self'; connect-src 'self' https: http://127.0.0.1:* http://localhost:* wss://collab.geolibre.app ws://127.0.0.1:* ws://localhost:*; img-src 'self' data: blob: https:; media-src 'self' blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' blob: 'unsafe-eval' 'wasm-unsafe-eval' https://cdn.jsdelivr.net/npm/ https://cdn.jsdelivr.net/pyodide/ https://accounts.google.com; child-src 'self' https://accounts.google.com https://www.google.com; frame-src 'self' https://accounts.google.com https://www.google.com; worker-src blob: 'self'",
2525
"capabilities": ["default"]
2626
}
2727
},

0 commit comments

Comments
 (0)