Commit 55cba0d
authored
* 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
File tree
- .github/workflows
- apps/geolibre-desktop
- src-tauri
- src
- components/layout
- hooks
- i18n/locales
- lib
- docs
- packages/core/src
- tests
- workers/collab
- src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
52 | 56 | | |
53 | 57 | | |
54 | 58 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
36 | 36 | | |
37 | 37 | | |
38 | 38 | | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
63 | 63 | | |
64 | 64 | | |
65 | 65 | | |
| 66 | + | |
66 | 67 | | |
67 | 68 | | |
68 | 69 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
24 | | - | |
| 24 | + | |
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| |||
0 commit comments