Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/components/menu/Controls.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import { setContext, tick } from 'svelte';
import { writable } from 'svelte/store';
import Objects from './Objects.svelte';
import LocalObjects from './LocalObjects.svelte';
import ContextMenu from '../ContextMenu.svelte';
import MobileAddButton from './MobileAddButton.svelte';
import AiHudButton from './AiHudButton.svelte';
Expand Down Expand Up @@ -776,8 +777,9 @@
{/if}
{:else}
{#if $objectsGroup}
<LocalObjects />
{#if $objectsGroup.children.length > 0}
{#each $objectsGroup.children as element}
{#each $objectsGroup.children.filter((/** @type {any} */ c) => !c.userData?.__localOnly) as element}
<Objects {element} />
{/each}
{/if}
Expand Down
124 changes: 124 additions & 0 deletions src/components/menu/LocalObjects.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<script>
// Viewer object-permissions: the "Local objects" section of the object list. Lists
// objects marked `__localOnly` (a viewer's creations that never reached peers), lets
// you REMOVE them, DRAG objects in (mark local-only), and — once you have edit
// access — SHARE them to peers (single or all). Shown only when the roles plugin is
// present (or there are local objects), so the OSS build is unchanged.
import { get } from 'svelte/store';
import { objectsGroup } from '../../stores/sceneStore';
import { peers, showToast } from '../../stores/appStore';
import { rolesInfo } from '$lib/cloudHooks';
import { clearLocalOnly, markLocalOnly } from '$lib/objectPermissions';
import { selectObject, deleteObjectsByUuid } from '$lib/objectActions';

let expanded = $state(true);
let dropHover = $state(false);

const local = $derived(($objectsGroup?.children || []).filter((/** @type {any} */ c) => c?.userData?.__localOnly));
const isViewerNow = $derived($rolesInfo?.myRole === 'viewer');
const show = $derived(!!$rolesInfo || local.length > 0);

/** poke the objectsGroup store so both lists re-render after a mark/share/remove */
function poke() {
const g = get(objectsGroup);
if (g) objectsGroup.set(g);
}

function share(/** @type {any} */ obj) {
if (isViewerNow) {
showToast('You need edit access to share — ask an admin to make you an editor.');
return;
}
clearLocalOnly(obj);
try {
get(peers)?.send({ type: 'object', element: obj.toJSON(), uuids: [obj.uuid] });
showToast('Shared "' + (obj.name || 'object') + '" with peers.');
} catch (e) {
console.warn('share failed', e);
}
poke();
}
function shareAll() {
for (const o of [...local]) share(o);
}
function remove(/** @type {any} */ obj) {
deleteObjectsByUuid([obj.uuid]);
poke();
}

function onDrop(/** @type {DragEvent} */ e) {
e.preventDefault();
dropHover = false;
const uuid = e.dataTransfer?.getData('application/x-object-uuid');
const obj = uuid && get(objectsGroup)?.getObjectByProperty('uuid', uuid);
if (obj) {
markLocalOnly(obj);
showToast('"' + (obj.name || 'object') + '" is now local-only.');
poke();
}
}
function onDragOver(/** @type {DragEvent} */ e) {
if (e.dataTransfer?.types?.includes('application/x-object-uuid')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
dropHover = true;
}
}
</script>

{#if show}
<div
class={'local-objs mb-1 rounded border ' +
(dropHover ? 'border-primary-400 bg-primary-900/20' : 'border-amber-500/30 bg-amber-500/5')}
role="group"
ondragover={onDragOver}
ondragleave={() => (dropHover = false)}
ondrop={onDrop}
>
<div class="flex items-center gap-1 px-1.5 py-1">
<button
class="flex min-w-0 flex-1 items-center gap-1.5 text-left text-xs font-medium text-amber-200"
title={expanded ? 'Collapse' : 'Expand'}
onclick={() => (expanded = !expanded)}
>
<i class={expanded ? 'fa-solid fa-chevron-down' : 'fa-solid fa-chevron-right'} style="font-size:9px"></i>
<i class="fa-solid fa-user-lock" style="font-size:10px"></i>
<span>Local objects</span>
<span class="rounded-full bg-amber-500/30 px-1.5 text-[10px]">{local.length}</span>
</button>
{#if local.length > 0 && !isViewerNow}
<button class="rounded bg-primary-700 px-1.5 py-0.5 text-[10px] text-white hover:bg-primary-600" title="Share all with peers" onclick={shareAll}>Share all</button>
{/if}
</div>

{#if expanded}
{#if local.length === 0}
<p class="px-2 pb-1.5 text-[10px] italic text-gray-400">
{isViewerNow
? 'Objects you create are kept here (view-only — peers can’t see them).'
: 'Drag an object here to keep it on your machine only.'}
</p>
{:else}
{#each local as obj (obj.uuid)}
<div class="group/lo flex items-center gap-1 px-1.5 py-0.5 text-xs hover:bg-gray-600/30">
<i class="fa-solid fa-cube w-3 shrink-0 text-center text-[10px] text-gray-400"></i>
<button
class="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-left text-gray-200"
onclick={() => selectObject(obj.uuid, false)}
title="Select">{obj.name || 'Object'}</button
>
<span class="hidden shrink-0 items-center gap-1 group-hover/lo:flex">
<button
class="rounded bg-primary-700 px-1.5 py-0.5 text-[10px] text-white hover:bg-primary-600 disabled:opacity-40"
disabled={isViewerNow}
title={isViewerNow ? 'Ask an admin for edit access to share' : 'Share with peers'}
onclick={() => share(obj)}>Share</button
>
<button class="text-gray-400 hover:text-red-400" title="Remove (delete)" onclick={() => remove(obj)}>✖</button>
</span>
</div>
{/each}
{/if}
{/if}
</div>
{/if}
2 changes: 2 additions & 0 deletions src/lib/commandsHandler.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,8 @@ export function sendObject(conn, element, groupuuid) {
}
// Iterate over all objects in the scene
objects.forEach(element => {
// viewer perms: never sync a viewer's local-only objects to a peer
if (element.userData && element.userData.__localOnly) return;
if (hasAnimatedImport(element.uuid)) {
// rigs travel as their original file bytes, one message
sendAnimatedImport(conn, element);
Expand Down
11 changes: 10 additions & 1 deletion src/lib/objectActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
showToast,
toggleExpand
} from '../stores/appStore';
import { canEditObject, warnViewerReadOnly } from './objectPermissions';

// Shared object selection used by the object list, viewport clicks and VR rays.
// Mirrors the original Objects.svelte behavior: selecting an unlocked object
Expand Down Expand Up @@ -96,7 +97,15 @@ export function applySelectionSet(uuids, openProperties = false) {
const primary = group.getObjectByProperty('uuid', clean[clean.length - 1]);
selectedObject.set(primary);
if (controls && !get(isVRMode)) {
if (clean.length === 1) {
// viewer perms: selecting/inspecting a shared object is fine, but deny the
// move gizmo unless every object in the set is editable by the local user
// (their own local-only objects, or anything for editors/admins).
const editable = clean.every((/** @type {any} */ uuid) => canEditObject(group.getObjectByProperty('uuid', uuid)));
if (!editable) {
releaseMultiPivot();
controls.detach();
warnViewerReadOnly();
} else if (clean.length === 1) {
releaseMultiPivot();
controls.attach(primary);
} else {
Expand Down
87 changes: 87 additions & 0 deletions src/lib/objectPermissions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Viewer object-permissions (roadmap: viewer-object-permissions). Only meaningful
// when the cloud plugin publishes roles via `rolesInfo` — without a plugin there are
// no roles and everyone is treated as an editor (zero gating, byte-unchanged OSS).
//
// Rules:
// - editor / admin (or no roles plugin): may edit anything.
// - viewer: may only move/edit objects THEY created locally (marked `__localOnly`,
// never broadcast). Everything shared by other peers is read-only for them.
import { get } from 'svelte/store';
import { rolesInfo } from './cloudHooks';
import { showToast } from '../stores/appStore';
import { objectsGroup } from '../stores/sceneStore';

/** broadcast message `type`s that CREATE a scene object (peerHandler send-gate) */
const CREATE_TYPES = new Set(['create', 'light', 'group', 'object', 'objectfile', 'duplicate']);

/** the local user's role, or null when there is no roles plugin */
export function localRole() {
return get(rolesInfo)?.myRole || null;
}
export function isViewer() {
return localRole() === 'viewer';
}

/** an object this viewer created locally (kept out of replication)
* @param {any} object */
export function isLocalOnly(object) {
return !!object?.userData?.__localOnly;
}
/** mark an object as local-only (viewer WIP that never reaches peers)
* @param {any} object */
export function markLocalOnly(object) {
if (object && object.userData) object.userData.__localOnly = true;
}
/** clear the flag when the object is shared (promoted user hits Share)
* @param {any} object */
export function clearLocalOnly(object) {
if (object?.userData) delete object.userData.__localOnly;
}

/** Can the LOCAL user move/edit this object?
* - no roles plugin -> yes
* - viewer -> only their own local-only objects
* - editor / admin -> yes
* @param {any} object */
export function canEditObject(object) {
if (!isViewer()) return true;
return isLocalOnly(object);
}

let lastReadOnlyWarn = 0;
/** throttled "you're view-only" nudge when a viewer tries to move a shared object */
export function warnViewerReadOnly() {
const now = Date.now();
if (now - lastReadOnlyWarn < 4000) return;
lastReadOnlyWarn = now;
showToast("View-only — you can't move objects shared by others. Ask an admin for edit access.");
}

let lastLocalWarn = 0;
/** throttled notice when a viewer creates something (it stays on their machine) */
export function warnViewerLocalCreate() {
const now = Date.now();
if (now - lastLocalWarn < 4000) return;
lastLocalWarn = now;
showToast('Created locally only — you are view-only, so peers will not see this. Share it from the object list once you have edit access.');
}

/** Send-side gate (called from PeerConnection.send). If the local user is a VIEWER
* and `data` is an object-CREATION broadcast, mark the created object(s) `__localOnly`
* + warn, and return true so the caller SKIPS the broadcast (peers drop it anyway via
* the receive-side capability gate). Returns false for everyone else / non-creations. */
export function gateCreationBroadcast(/** @type {any} */ data) {
if (!data || !CREATE_TYPES.has(data.type) || !isViewer()) return false;
const group = get(objectsGroup);
/** @type {any[]} */
const ids = [];
if (data.uuid) ids.push(data.uuid);
if (Array.isArray(data.uuids)) ids.push(...data.uuids);
if (data.element?.object?.uuid) ids.push(data.element.object.uuid);
for (const id of ids) {
const o = group?.getObjectByProperty('uuid', id);
if (o) markLocalOnly(o);
}
warnViewerLocalCreate();
return true;
}
4 changes: 4 additions & 0 deletions src/lib/peerHandler.svelte.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Peer from 'peerjs';
import { backoffDelay } from '$lib/netBackoff';
import { sceneCommand, lockRestore, checkLocks, createObject, sendObjects, deleteObject, colorObject, createLoader, userData, handleDisconnected, specator, cameraSettings, objectParameters, applyClearScene } from './commandsHandler.svelte';
import { gateCreationBroadcast } from './objectPermissions';
import { createGeometry, createLight, createGroup, changeName, moveGeometry, lockGeometry, moveCamera } from '$lib/geometries.svelte';
import { sendNodes, applyNodesSnapshot, applyNodeSync, createFlowNode, moveFlowNode, updateFlowNodeData, deleteFlowNodes, createFlowEdge, deleteFlowEdges, applyFlowCursor } from '$lib/nodesHandler';
import { applyGraphCreate, applyGraphDelete } from '$lib/flowGraphs';
Expand Down Expand Up @@ -630,6 +631,9 @@ export class PeerConnection {

/** @param {any} data */
send(data) {
// viewer perms: a viewer's object CREATIONS never leave this machine — mark
// them local-only + warn, and skip the broadcast (peers drop them anyway).
if (gateCreationBroadcast(data)) return;
if(data.type == 'create')
this.sendMessage('created a ' + data.command.split(' ')[1], 'info');
this.broadcast(data);
Expand Down