diff --git a/OPEN-CORE.md b/OPEN-CORE.md
index d8835e273..1f4b9bbd4 100644
--- a/OPEN-CORE.md
+++ b/OPEN-CORE.md
@@ -118,6 +118,17 @@ Consulted in `handleConnection` when an unknown peer connects: if the auth provi
Default (no provider) keeps the whitelist + approval flow byte-identical. `authorize`
is a synchronous lookup against state the plugin maintains from its own async login.
+**Automatic admissions are never silent (fix-live F-5).** Whenever `decide` answers
+`'admit'` (or `authorize` returns true), core shows the HOST a toast and a
+notification-history entry that keeps its buttons until answered: **Remove** (closes the
+link, drops the roster row, refuses that peer's re-dials while the tab stays open) and,
+when the provider offers it, a switch-off. The optional provider member
+`admitNotice(peerId, cloudMeta) → {text?, turnOff?, turnOffLabel?} | null` supplies the
+line (≤ 200 chars) and the switch-off; absent or null = a generic line, no switch-off.
+`api.setConnectBadge({text, title?, onClick?} | null)` puts a small badge on the Connect
+pill (`#connect-setting-badge`, ≤ 32 chars) for a standing setting such as "Public ·
+guests join"; a press runs `onClick` or opens the drawer's Rooms tab.
+
### 3. UI mount points
Roles replicate over the **plugin message channel**: an admin broadcasts the roles
@@ -248,6 +259,9 @@ static|embed>`. Core never talks to a server about it.
| `loadRemoteScene({…, keepGameId?})` | `keepGameId: true` = the OWNER opening their own published scene (no fork); otherwise a community load is a remix |
| `onExportBuilt(fn)` → off | after an export zip is built: `fn({gameId, buildId, preset, title, template, parentGameId, appVersion})` |
| `onTemplateOpen(fn)` → off | a Games-tab template was started: `fn(slug)` (one "template open") |
+| `toast(message, actions?, {keep?})` | fix-live F-2: with `keep: true` an action toast's buttons also stay on its notification-history entry until one is pressed (a role offer the user sees later) |
+| `setPeerMenuItems(items)` | fix-live: `[{id, label, run(peerId, {name})}]` (≤ 8, labels ≤ 40) appended to the Users peer menu (right-click a peer) under core's own entries — the cloud's "Report…"; `[]` clears |
+| `setConnectBadge(badge \| null)` | fix-live F-5: `{text, title?, onClick?}` — a small badge on the Connect pill for a standing setting (e.g. "Public · guests join"); `null` hides it. Probe with `typeof` |
| `setSceneHeart(info \| null)` | the heart on the play-link start card (`#embed-heart`) and a game's pause menu (`#game-shell-heart`): `{count, liked, toggle() → Promise<{liked, count} \| null>}`; `null` from `toggle` rolls the optimistic flip back |
The community provider gains three optional entry fields — `liked` (boolean, the viewer's own
diff --git a/src/components/menu/Connect.svelte b/src/components/menu/Connect.svelte
index 712cca483..533f8b5be 100644
--- a/src/components/menu/Connect.svelte
+++ b/src/components/menu/Connect.svelte
@@ -14,7 +14,7 @@
import { signalingRetry, formatElapsed, approvalStartedAt, approvalRemaining, APPROVAL_WINDOW_MS, joinRefusal, clearJoinRefusal, HARD_PEER_CAP } from '$lib/connectionState';
import { cancelOutboundRequest, requestConnect } from '$lib/peerApproval';
import { sessionHost } from '$lib/connectionState';
- import { connectSlot, drawerSlot } from '$lib/cloudHooks';
+ import { connectSlot, drawerSlot, connectBadge } from '$lib/cloudHooks';
// 41 G21: the status is a DOT (grey / yellow pulsing / green / red); its words ride title + aria-label
import { connectionStatus } from '$lib/connectionStatus';
import CloudSlot from '../CloudSlot.svelte';
@@ -659,6 +659,19 @@
>
{/if}
+ {#if $connectBadge && !squeezedView}
+
+
+ {/if}
+
{#if $drawerSlot && $showRoomsButton && !squeezedView}
@@ -864,6 +877,21 @@
background: color-mix(in srgb, var(--ink-bad) 16%, transparent);
cursor: pointer;
}
+ .cx-setting-badge {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ height: 20px;
+ padding: 0 8px;
+ border: 0;
+ border-radius: var(--radius-pill);
+ font-size: var(--fs-badge);
+ font-weight: 600;
+ white-space: nowrap;
+ color: var(--ink-warn);
+ background: color-mix(in srgb, var(--ink-warn) 16%, transparent);
+ cursor: pointer;
+ }
.cx-refused[data-result='full'] {
color: var(--ink-warn);
background: color-mix(in srgb, var(--ink-warn) 16%, transparent);
diff --git a/src/components/menu/NotificationCenter.svelte b/src/components/menu/NotificationCenter.svelte
index 18f3de554..1c0656e57 100644
--- a/src/components/menu/NotificationCenter.svelte
+++ b/src/components/menu/NotificationCenter.svelte
@@ -7,7 +7,7 @@
// missed (or dismissed while a modal was open) is still recoverable. Placed in the
// top-right chrome next to the peers/profile cluster (Users.svelte), mirroring the
// peers popover pattern (absolute dropdown, click-catcher backdrop).
- import { notifications, notificationsUnread, notificationCenterOpen } from '../../stores/appStore.js';
+ import { notifications, notificationsUnread, notificationCenterOpen, notificationActions } from '../../stores/appStore.js';
function toggle() {
const willOpen = !$notificationCenterOpen;
@@ -18,6 +18,16 @@
function clearAll() {
notifications.set([]);
notificationsUnread.set(0);
+ notificationActions.set({});
+ }
+
+ /** F-5: the newest entry of a group that still offers buttons @param {any[]} items */
+ function liveActionsOf(items) {
+ for (let i = items.length - 1; i >= 0; i--) {
+ const acts = $notificationActions[items[i].id];
+ if (acts?.length) return acts;
+ }
+ return null;
}
// NOTES-38 #13 (user): a message that repeats ("Cannot reach the peer server. Retrying…"
@@ -95,6 +105,13 @@
{g.text}{ago(g.latest.ts)}
+ {#if liveActionsOf(g.items)}
+
+ {#each liveActionsOf(g.items) ?? [] as a (a.label)}
+
+ {/each}
+
+ {/if}
{#if g.items.length > 1 && open[g.text]}
{#each [...g.items].reverse() as n (n.id)}
@@ -129,6 +146,24 @@
.notif-pop :global(.wc-body) {
max-height: 60vh;
}
+ .notif-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 4px;
+ }
+ .notif-act {
+ font-size: var(--fs-badge);
+ padding: 2px 8px;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--accent);
+ cursor: pointer;
+ }
+ .notif-act:hover {
+ background: var(--surface-hover);
+ }
.notif-list {
margin: 0;
padding: 4px 0;
diff --git a/src/components/menu/Users.svelte b/src/components/menu/Users.svelte
index 79434f5c3..0f0da2595 100644
--- a/src/components/menu/Users.svelte
+++ b/src/components/menu/Users.svelte
@@ -111,7 +111,7 @@
import { toolbarSlots, slotOrder, TOP_DEFAULT } from '$lib/toolbarSlots';
import { phoneShell } from '$lib/ui/phoneShell.js';
import CloudSlot from '../CloudSlot.svelte';
- import { usersSlot, profileSlot, rolesInfo, scenePresence } from '$lib/cloudHooks';
+ import { usersSlot, profileSlot, rolesInfo, scenePresence, peerMenuItems } from '$lib/cloudHooks';
import { safeStorage } from '$lib/safeStorage';
// 45 N4: a pinned / closed right sidebar moves the profile circle while its menu may be open
import { geometry as sidebarGeometry } from '$lib/sidebars';
@@ -122,7 +122,7 @@
// 53 A2: and its SHAPE, so the band never rides on colour alone ($lib/networkQuality)
let openDropdown = $state(false);
- let muteMenu = $state(null);
+ let muteMenu = $state<{ x: number; y: number; peerId: string } | null>(null);
// 130: the peers overflow is a proper popover listing EVERY peer (none
// hidden by stacking) with a labeled Watch affordance
/** @param {string} id */
@@ -1120,7 +1120,15 @@
{
label: $mutedPeers.includes(muteMenu.peerId) ? 'Unmute voice' : 'Mute voice',
action: () => toggleMutePeer(muteMenu.peerId)
- }
+ },
+ // fix-live: the plugin's entries (the cloud's "Report…"), with the name the roster shows
+ ...$peerMenuItems.map((item) => {
+ const pid = muteMenu?.peerId ?? '';
+ return {
+ label: item.label,
+ action: () => item.run(pid, { name: String($userdata.find((e) => e[0] === pid)?.[1] ?? '') })
+ };
+ })
]}
on:close={() => (muteMenu = null)}
/>
diff --git a/src/lib/admitNotice.js b/src/lib/admitNotice.js
new file mode 100644
index 000000000..45024e075
--- /dev/null
+++ b/src/lib/admitNotice.js
@@ -0,0 +1,25 @@
+// F-5 (roadmap 55 "Fixes for 54", test flight T14): an automatic admission is never silent.
+// The cloud rooms plugin admits guests to a listed room with "Allow guests to join
+// automatically" on, and the host saw nothing at all — no card, no toast, no history. Core
+// now tells the host about EVERY automatic admission; the auth provider may say why and
+// offer to switch the setting off (authProvider.admitNotice). Pure: no stores, no peers.
+
+/** @param {string} peerId */
+export function shortPeer(peerId) {
+ return String(peerId ?? '').slice(0, 6).toUpperCase();
+}
+
+/**
+ * @param {string} peerId
+ * @param {any} provided what `authProvider.admitNotice(peerId, cloudMeta)` returned (or threw → null)
+ * @returns {{ text: string, turnOff: (() => void) | null, turnOffLabel: string }}
+ */
+export function composeAdmitNotice(peerId, provided) {
+ const p = provided && typeof provided === 'object' ? provided : null;
+ const text =
+ String(p?.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 200) ||
+ shortPeer(peerId) + ' joined automatically — your session settings let them in without approval.';
+ const turnOff = typeof p?.turnOff === 'function' ? p.turnOff : null;
+ const turnOffLabel = String(p?.turnOffLabel ?? '').trim().slice(0, 24) || 'Turn off';
+ return { text, turnOff, turnOffLabel };
+}
diff --git a/src/lib/ai/aiPhysicsDefault.js b/src/lib/ai/aiPhysicsDefault.js
new file mode 100644
index 000000000..f63e0dcb6
--- /dev/null
+++ b/src/lib/ai/aiPhysicsDefault.js
@@ -0,0 +1,24 @@
+// fix-live (roadmap 55 "Fixes for 54" F-4, test flight T8): what the AI tools' create does
+// about physics. The Add menu stamps standard primitives DYNAMIC, mass 1 (geometries.svelte.js,
+// "fun by default": a cube you drop falls). Right for one cube a person places; wrong for a
+// scene an AI builds out of 40 of them — every wall, rock and roof beam fell over the moment
+// Play started a sim. AI-made objects are STATIC unless the request asks for physics
+// (`physics: 'dynamic'` or a `mass`). Pure: the caller writes the patch through setPhysicsFor.
+
+/**
+ * @param {any} spec one create_objects entry
+ * @param {any} stamped the `userData.physics` the builder stamped (or undefined)
+ * @returns {{mode: 'static'} | {mode: 'dynamic', mass: number} | null} the patch, or null for none
+ */
+export function aiCreatePhysics(spec, stamped) {
+ const mass = Number(spec?.mass);
+ const wantsMass = spec?.mass != null && Number.isFinite(mass) && mass > 0;
+ if (spec?.physics === 'dynamic' || wantsMass) {
+ const m = wantsMass ? Math.min(mass, 10000) : 1;
+ if (stamped?.mode === 'dynamic' && (stamped.mass ?? 1) === m) return null;
+ return { mode: 'dynamic', mass: m };
+ }
+ // nothing asked: only an object the builder made dynamic needs turning back
+ if (stamped?.mode === 'dynamic') return { mode: 'static' };
+ return null;
+}
diff --git a/src/lib/ai/tools.js b/src/lib/ai/tools.js
index 6d31f429a..12b542ba7 100644
--- a/src/lib/ai/tools.js
+++ b/src/lib/ai/tools.js
@@ -12,6 +12,10 @@ import {
import { setObjectColor, switchMaterialType, setMaterialParam } from '$lib/materialsHandler';
import { notifyExternalMove } from '$lib/flowRuntime';
import { meshGenReady } from './meshProviders.js';
+import { viewerToolRefusal } from './viewerGate.js';
+import { aiCreatePhysics } from './aiPhysicsDefault.js';
+import { setPhysicsFor } from '$lib/physics';
+import { isViewer } from '$lib/objectPermissions';
import { graphOf, SCENE_GRAPH } from '../../stores/flowStore';
import { sceneJoints } from '$lib/joints';
import { graphToText } from '$lib/graphText';
@@ -321,7 +325,10 @@ function createOne(spec) {
const parent = objectOf(spec.parentUuid);
if (parent && parent.type === 'Group') moveObjectToGroup(uuid, spec.parentUuid);
}
- return { uuid, name: created.name };
+ // fix-live F-4: static unless asked — a built scene must not fall apart in Play
+ const physics = aiCreatePhysics(spec, created.userData?.physics);
+ if (physics) setPhysicsFor(uuid, physics);
+ return { uuid, name: created.name, ...(created.userData?.physics?.mode ? { physics: created.userData.physics.mode } : {}) };
}
/** @param {any} raw @returns {string|null} */
@@ -562,6 +569,9 @@ const PHYSICS_DISABLED =
*/
export async function executeAiTool(rawName, rawArgs) {
const { name, args } = repairToolCall(rawName, rawArgs);
+ // F-1: a viewer's edits never reach anyone, so refuse instead of reporting success
+ const refusal = viewerToolRefusal(name, isViewer());
+ if (refusal) return refusal;
try {
switch (name) {
case 'list_scene':
@@ -729,7 +739,13 @@ export const AI_TOOLS = [
},
scale: { type: 'array', items: { type: 'number' }, description: '[x,y,z]' },
color: { type: 'string', description: 'hex like #ff8800 (meshes only)' },
- parentUuid: { type: 'string', description: 'uuid of an existing group to nest under' }
+ parentUuid: { type: 'string', description: 'uuid of an existing group to nest under' },
+ physics: {
+ type: 'string',
+ enum: ['static', 'dynamic'],
+ description: 'default static (it stays put in Play). dynamic = it falls, collides and can be thrown — only when asked for'
+ },
+ mass: { type: 'number', description: 'kg; implies physics dynamic' }
},
required: ['kind']
}
@@ -947,7 +963,7 @@ const PHYSICS_AI_TOOLS = [
function: {
name: 'set_physics',
description:
- 'Set physics body params on objects (batch). NOTE: new primitives already spawn dynamic with mass 1 — use this mostly to TUNE bodies or make scenery immovable (mode "static" for ground/walls). mode "auto" reverts to scenery defaults.',
+ 'Set physics body params on objects (batch). NOTE: objects made with create_objects are STATIC unless created with physics "dynamic" or a mass — use this to make existing objects dynamic, to TUNE bodies, or to pin scenery (mode "static"). mode "auto" reverts to scenery defaults.',
parameters: {
type: 'object',
properties: {
diff --git a/src/lib/ai/viewerGate.js b/src/lib/ai/viewerGate.js
new file mode 100644
index 000000000..1dfb75528
--- /dev/null
+++ b/src/lib/ai/viewerGate.js
@@ -0,0 +1,24 @@
+// F-1 (roadmap 55 "Fixes for 54", test flight T1): a VIEWER's AI tool call must never
+// report success for an edit nobody else will see. Their creations are kept local by the
+// send-gate (objectPermissions.gateCreationBroadcast) and their changes to shared objects
+// are dropped by every receiver, so `{ created: [...] }` was a lie the model (or an
+// external agent) built on. Viewers get a refusal that says why and what to do instead.
+// Pure: the caller passes the role, so this stays unit-testable without the scene.
+
+/** tools that never change the scene or anything replicated. Simulation playback is
+ * local (viewers may press Play like anyone else), so it stays allowed. */
+const VIEWER_SAFE_TOOLS = new Set(['list_scene', 'control_simulation']);
+
+export const VIEWER_REFUSAL =
+ 'view-only: you are a viewer in this session, so nothing you create or change would reach the other people here. ' +
+ 'Ask the host or an admin for edit access, then try again. Reading the scene (list_scene) still works.';
+
+/**
+ * @param {string} name the (repaired) tool name
+ * @param {boolean} viewer is the local user a viewer?
+ * @returns {{ error: string, viewOnly: true } | null} the refusal, or null to run the tool
+ */
+export function viewerToolRefusal(name, viewer) {
+ if (!viewer || VIEWER_SAFE_TOOLS.has(name)) return null;
+ return { error: VIEWER_REFUSAL, viewOnly: true };
+}
diff --git a/src/lib/cloudHooks.js b/src/lib/cloudHooks.js
index ba14693fd..43c2b4540 100644
--- a/src/lib/cloudHooks.js
+++ b/src/lib/cloudHooks.js
@@ -76,7 +76,16 @@ const ALWAYS_ALLOWED = new Set([
// reply and every update stay gateable, which is what keeps a viewer read-only.
'getytext',
// 60 Q5: the shared reference images REQUEST (the `underlay` replies stay gateable)
- 'getunderlays'
+ 'getunderlays',
+ // fix-live (plans review #5, roadmap 49 follow-up): the last three `get*` requests off the
+ // floor. A viewer asking for the project manifest, a missing asset's bytes (or thumbnail) or
+ // one big material preset was DROPPED, so a cloud viewer's Explorer never filled in and a
+ // shared texture or preset stayed missing. Each only asks; the answerer sends what it already
+ // shares, and the replies (`manifest`, `assetfile`/`assetstart`/`assetchunk`/`assetmissing`,
+ // `matpresetpart`) stay gateable.
+ 'getproject',
+ 'getasset',
+ 'getmatpreset'
]);
/** @type {((peerId: string, msgType: string) => boolean) | null} */
@@ -126,6 +135,10 @@ export function hasCapabilityProvider() {
* present it replaces `authorize`: `cloudMeta` is the joiner's dial data
* (cloudApi.connectToPeer's 2nd arg, null when absent); 'deny' refuses with no
* approval card; an object is the normal card, carrying `label`.
+ * admitNotice(peerId, cloudMeta): {text?, turnOff?, turnOffLabel?} | null — F-5 (fix-live,
+ * optional). Core tells the HOST about EVERY automatic admission (toast + notification
+ * history, with Remove); the provider says WHY in `text` and may offer `turnOff()` (e.g.
+ * switch "Allow guests to join automatically" off). Null/absent = the generic notice.
* @type {any}
*/
let authProvider = null;
@@ -413,3 +426,43 @@ export const sceneHeart = writable(null);
export function setSceneHeart(info) {
sceneHeart.set(info && typeof info === 'object' && typeof info.toggle === 'function' ? info : null);
}
+
+/**
+ * F-5 (fix-live): A STANDING SETTING THAT IS NEVER INVISIBLE — a small badge on the Connect
+ * pill, e.g. "Public · guests join" while a listed room auto-admits guests. The plugin sets it
+ * (cloudApi.setConnectBadge) whenever the setting changes; null hides it. Shape:
+ * { text: string (≤ 32 chars), title?: string (tooltip, ≤ 200), onClick?: () => void }
+ * @type {import('svelte/store').Writable<{text: string, title: string, onClick: (() => void) | null} | null>} */
+export const connectBadge = writable(null);
+
+/** @param {any} badge */
+export function setConnectBadge(badge) {
+ const text = badge && typeof badge === 'object' ? String(badge.text ?? '').trim().slice(0, 32) : '';
+ connectBadge.set(
+ text
+ ? {
+ text,
+ title: String(badge.title ?? '').slice(0, 200),
+ onClick: typeof badge.onClick === 'function' ? badge.onClick : null
+ }
+ : null
+ );
+}
+
+/**
+ * fix-live (reports): entries a plugin adds to the Users PEER menu (right-click a peer), under
+ * core's own. `run(peerId, {name})` gets the peer's session id and the name the roster shows.
+ * The cloud plugin's "Report…" lives here. Set via cloudApi.setPeerMenuItems; [] clears.
+ * @type {import('svelte/store').Writable<{id: string, label: string, run: (peerId: string, info: {name: string}) => void}[]>} */
+export const peerMenuItems = writable([]);
+
+/** @param {any} items */
+export function setPeerMenuItems(items) {
+ const list = Array.isArray(items) ? items : [];
+ peerMenuItems.set(
+ list
+ .filter((i) => i && typeof i.run === 'function' && String(i.label ?? '').trim())
+ .slice(0, 8)
+ .map((i, n) => ({ id: String(i.id ?? 'item-' + n).slice(0, 40), label: String(i.label).trim().slice(0, 40), run: i.run }))
+ );
+}
diff --git a/src/lib/cloudPlugin.js b/src/lib/cloudPlugin.js
index e995e99d3..36ac29f1d 100644
--- a/src/lib/cloudPlugin.js
+++ b/src/lib/cloudPlugin.js
@@ -30,7 +30,7 @@ import { safeStorage } from './safeStorage';
// (svelte/store + safeStorage), so a static edge from here closes nothing.
import { aiProviders, aiActiveProvider, aiEnabled, addAiProvider, updateAiProvider, removeAiProvider, setAiActiveProvider, setAiEnabled } from './ai/providers';
import { meshProviders, meshActiveProvider, meshGenEnabled, addMeshProvider, updateMeshProvider, removeMeshProvider, setMeshActiveProvider, setMeshGenEnabled } from './ai/meshProviders';
-import { meshJobStatus, onExportBuilt, onTemplateOpen, setSceneHeart, problemReporter, setWhatsNewCloud } from './cloudHooks';
+import { meshJobStatus, onExportBuilt, onTemplateOpen, setSceneHeart, problemReporter, setWhatsNewCloud, setConnectBadge, setPeerMenuItems } from './cloudHooks';
// 36-community (C4): a leaf (svelte/store only) — safe as a static edge
import { gameIdentity, ensureGameId, forkGameId } from './gameIdentity.js';
import { exportMode } from './export/exportBoot.js';
@@ -539,6 +539,12 @@ export function makeCloudApi() {
/** C6: the heart for the scene on screen (play-link start card + pause menu):
* `{count, liked, toggle() → Promise<{liked, count} | null>}`, or null to remove it. */
setSceneHeart: (/** @type {any} */ info) => setSceneHeart(info),
+ /** F-5 (fix-live): a small badge on the Connect pill for a standing setting the host
+ * should never forget ({text, title?, onClick?}; null hides). Probe with typeof. */
+ setConnectBadge: (/** @type {any} */ badge) => setConnectBadge(badge),
+ /** fix-live: entries for the Users peer menu, `[{id, label, run(peerId, {name})}]` (≤ 8;
+ * [] clears) — the cloud's "Report…". Probe with typeof. */
+ setPeerMenuItems: (/** @type {any} */ items) => setPeerMenuItems(items),
// --- CL-5 continuity (roadmap 37 / R13, 37-continuity): ADDITIVE, typeof-probed, no bump ---
/** A counter that moves on EVERY scene change — objects, graphs, animation, looks, sky, physics,
@@ -602,6 +608,8 @@ export function makeCloudApi() {
},
// --- utilities ---
+ /** toast(message, actions?, {keep?}) — fix-live F-2: `keep: true` leaves the buttons on the
+ * notification-history entry until one is pressed (e.g. a role offer) */
toast: showToast
};
}
diff --git a/src/lib/peerDrop.js b/src/lib/peerDrop.js
new file mode 100644
index 000000000..59f627f1f
--- /dev/null
+++ b/src/lib/peerDrop.js
@@ -0,0 +1,104 @@
+// F-3 (roadmap 55 "Fixes for 54", test flight T5): a peer that vanished without a goodbye
+// (a killed process, a crashed tab) must leave Users within seconds, and a leave must never
+// surface as a raw "Connection error". Imports NOTHING: the rules are numbers and a set, so
+// they are unit-testable with no peer; peerHandler owns the timers and the conns.
+//
+// Three facts the fix rests on (peerjs 1.5.5, Chromium):
+// - a peer that dies without closing its SCTP association leaves our data channel "open":
+// ICE goes `disconnected` after a few silent seconds and may sit there for 30 s or more
+// before `failed` (or never), so the conn's 'close' never fires and the row is a ghost.
+// - a dial to an id the signaling server no longer holds answers EXPIRE -> a PEER-level
+// `peer-unavailable` error, and the conn it named stays un-open (peerjs does not close
+// it), so the reconnect loop waited out the 10 s dial grace per try, toasting each time.
+// - candidates/descriptions arriving for a connection we just closed fail as a PEER-level
+// `webrtc` error, which the catch-all turned into "Connection error: webrtc".
+
+/** how long ICE may sit in `disconnected` before we treat the link as dropped. Chromium
+ * recovers a lossy-but-alive link well inside this; a dropped one goes to the reconnect
+ * window (the B5 path), so a live peer is redialled, never lost. */
+export const ICE_DROP_GRACE_MS = 4000;
+
+/** how often a live link's ICE stats are read, and how long its consent checks may go
+ * unanswered before the link counts as dead. libwebrtc pings a writable pair every ≤ 2.5 s, so
+ * 7 s is two to three missed answers; ICE's own `disconnected` took ~15 s to show here (two
+ * Chromium peers, one renderer crashed) and `failed` 30 s+ or never. */
+export const LINK_POLL_MS = 2000;
+export const LINK_SILENT_MS = 7000;
+
+/**
+ * Read the selected candidate pair's consent counters out of an RTCStatsReport-like map.
+ * @param {Iterable | {forEach: Function} | null | undefined} report
+ * @returns {{ requestsSent: number, responsesReceived: number } | null}
+ */
+export function consentCounters(report) {
+ if (!report) return null;
+ /** @type {any[]} */
+ const rows = [];
+ if (typeof (/** @type {any} */ (report).forEach) === 'function') /** @type {any} */ (report).forEach((/** @type {any} */ r) => rows.push(r));
+ else for (const r of /** @type {Iterable} */ (report)) rows.push(r);
+ const transport = rows.find((r) => r?.type === 'transport' && r.selectedCandidatePairId);
+ const pair =
+ (transport && rows.find((r) => r?.id === transport.selectedCandidatePairId)) ||
+ rows.find((r) => r?.type === 'candidate-pair' && (r.selected || (r.nominated && r.state === 'succeeded')));
+ if (!pair || !Number.isFinite(pair.requestsSent) || !Number.isFinite(pair.responsesReceived)) return null;
+ return { requestsSent: pair.requestsSent, responsesReceived: pair.responsesReceived };
+}
+
+/**
+ * One poll of the silence watch. `state` carries the last answer count and when it last
+ * moved; a link is SILENT when we kept asking (requestsSent moved) and nothing answered for
+ * LINK_SILENT_MS. No counters (stats unsupported, no selected pair yet) = never silent.
+ * @param {{ responses: number, requests: number, at: number } | null} state
+ * @param {{ requestsSent: number, responsesReceived: number } | null} counters
+ * @param {number} now
+ * @returns {{ state: { responses: number, requests: number, at: number } | null, silent: boolean }}
+ */
+export function linkSilence(state, counters, now) {
+ if (!counters) return { state, silent: false };
+ if (!state || counters.responsesReceived !== state.responses) {
+ return { state: { responses: counters.responsesReceived, requests: counters.requestsSent, at: now }, silent: false };
+ }
+ const asked = counters.requestsSent > state.requests;
+ return { state, silent: asked && now - state.at >= LINK_SILENT_MS };
+}
+
+/** "this id is not on the signaling server" answers, while redialling a dropped peer,
+ * before we stop waiting for them. The server answers a dial to an unknown id only when the
+ * offer EXPIRES (~5 s, peerjs-server `expire_timeout`), so answers are slow: a channel that
+ * CLOSED (a wifi hop, a lid) takes three — a signaling blip on their side heals inside that —
+ * while a link we closed for SILENCE (ICE quiet past the grace: nothing has come from them
+ * for 10 s already) takes one. Measured on two Chromium peers, one renderer crashed: the
+ * three-answer rule could not land inside the 15.5 s reconnect window at all. */
+export const UNAVAILABLE_STRIKES = 3;
+
+/** @param {boolean} silent was the link closed for ICE silence? @returns {number} */
+export function strikesNeeded(silent) {
+ return silent ? 1 : UNAVAILABLE_STRIKES;
+}
+
+/** after a leave, a drop or a redial, peer-level noise for this long is a symptom of
+ * that, never news. */
+export const LEAVE_QUIET_MS = 20000;
+
+const QUIET_TYPES = new Set(['webrtc', 'disconnected', 'negotiation-failed', 'connection-closed']);
+
+/**
+ * Should a peer-level error of `type` stay off the screen? Only the leave/drop symptoms,
+ * and only while a leave or a redial is in flight (or just was).
+ * @param {string} type peerjs error type
+ * @param {{ lastLeaveAt: number, reconnecting: number, now: number }} ctx
+ */
+export function quietPeerError(type, { lastLeaveAt, reconnecting, now }) {
+ if (!QUIET_TYPES.has(type)) return false;
+ return reconnecting > 0 || now - lastLeaveAt < LEAVE_QUIET_MS;
+}
+
+/**
+ * Pull the peer id out of a peerjs `peer-unavailable` message
+ * ("Could not connect to peer abc12"). The id is the LAST word: the first-match regex this
+ * replaces answered "Could" for every message, so the request it meant to end never ended.
+ * @param {unknown} message
+ */
+export function unavailablePeerId(message) {
+ return String(message ?? '').match(/peer\s+([0-9a-z][0-9a-z_-]{2,})\s*$/i)?.[1] ?? '';
+}
diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js
index 73b5fa4de..78b675d92 100644
--- a/src/lib/peerHandler.svelte.js
+++ b/src/lib/peerHandler.svelte.js
@@ -35,6 +35,8 @@ import { createFastLinks, createStaleFilter, createKeyframer, stampPose, poseKey
import { registerDebugHook } from '$lib/debugHooks';
import { routePerfLive, perfLivePeerGone } from '$lib/perf/liveWire.js'; // 34 PF: the live perf stream (an import-free leaf)
import { noteWireError } from '$lib/wireErrors';
+import { composeAdmitNotice, shortPeer } from '$lib/admitNotice';
+import { ICE_DROP_GRACE_MS, LINK_POLL_MS, strikesNeeded, quietPeerError, unavailablePeerId, consentCounters, linkSilence } from '$lib/peerDrop';
import { noteWire } from '$lib/sceneBudget';
import { applyAnnotation, applyAnnotationsSnapshot, sendAnnotations } from '$lib/annotationsHandler';
import { applyPing } from '$lib/ping';
@@ -115,7 +117,7 @@ import { applyObjectFile } from '$lib/animatedImports';
import { applyRemoteBehavior } from '$lib/packBehavior';
import { sendArchBehaviorStates } from '$lib/arch/archParts.js';
import { lockedObjects, selectedObject, peerHands, objectsGroup, pokeScene } from '../stores/sceneStore';
-import { addMessage, peers, userdata, pendingApprovals, waitingForApproval, showToast, pushNotification, chatHistory, mergeChatHistory } from '../stores/appStore';
+import { addMessage, peers, userdata, pendingApprovals, waitingForApproval, showToast, showLastingToast, pushNotification, chatHistory, mergeChatHistory } from '../stores/appStore';
// 41 G21: the signaling link's ups and downs are a STATE on the Connect pill's dot, never toasts
import { GIVE_UP_ATTEMPTS } from '$lib/connectionStatus';
import { get } from 'svelte/store';
@@ -285,6 +287,18 @@ export class PeerConnection {
/** @type {Set} peers that ANNOUNCED they were leaving (the
* `disconnected` self-message) — their close is expected, don't redial */
this.gracefulLeft = new Set();
+ /** F-3: when a peer last left or dropped (or we left) — peer-level errors right after
+ * one are its symptoms, not news (peerDrop.quietPeerError) */
+ this.lastLeaveAt = 0;
+ /** F-3: peerId -> "not on the signaling server" answers while we redial them
+ * @type {Map} */
+ this.unavailableStrikes = new Map();
+ /** F-3: peers whose link WE closed for ICE silence — one "not on the server" answer
+ * then ends the wait (peerDrop.strikesNeeded) @type {Set} */
+ this.silentDrops = new Set();
+ /** F-5: peers the host removed from an automatic admission — their re-dials are
+ * refused for the rest of this tab's session, guests setting or not @type {Set} */
+ this.removedPeers = new Set();
/** @type {Set} peers we mean to exchange FULL STATE with — the
* host we're joining (requestConnect) or a joiner we approved. Mesh-fill
* dials (the `hosts` flow) don't request state, and an adopted inbound
@@ -602,9 +616,15 @@ export class PeerConnection {
if (err.type === 'peer-unavailable') {
// 27-E: end the request this names. The pill used to sit on "Requesting"
// beside this very toast, and the optimistic whitelist row never went away.
- const id = String(err.message ?? '').match(/[0-9a-z]{3,}/i)?.[0] ?? '';
+ const id = unavailablePeerId(err.message);
// 25-F: a refusal to somebody who already gave up and left is not news
if (id && this.refusalDials.has(id)) return;
+ // F-3: a REDIAL of a peer that dropped — the server no longer holds them. Not
+ // news either; a few answers in a row mean they are gone, so stop waiting.
+ if (id && this.reconnecting.has(id)) {
+ this.noteUnavailable(id);
+ return;
+ }
if (id) import('$lib/peerApproval').then((m) => m.abandonOutboundRequest(id)).catch(() => {});
showToast('Peer is unreachable. Check the ID and ask them to stay online.');
} else if (err.type === 'unavailable-id') {
@@ -613,6 +633,10 @@ export class PeerConnection {
// 41 G21: no toast — a link that was up retries through 'disconnected'/'close' (the
// dot pulses); one that NEVER opened has nothing else to say it, so it is failed
if (!this.hasOpened) signalingDown(Math.max(GIVE_UP_ATTEMPTS, get(signalingRetry).attempt || 0));
+ } else if (quietPeerError(err.type, { lastLeaveAt: this.lastLeaveAt, reconnecting: this.reconnecting.size, now: Date.now() })) {
+ // F-3: the tail of a leave or a drop (late candidates for a closed link) — the
+ // leave itself is what the user hears about
+ log('info', 'net', 'peer error during a leave — not shown', { type: err.type });
} else {
showToast('Connection error: ' + err.type);
}
@@ -712,6 +736,12 @@ export class PeerConnection {
// 'admit' | 'deny' | {label} (knock). 'deny' refuses with no card — told to a
// joiner that can hear it. Without `decide`, `authorize` as before.
const cloudMeta = conn?.metadata?.cloud ?? null;
+ // F-5: someone the host removed stays out (the setting would just let them back)
+ if (this.removedPeers.has(conn.peer)) {
+ if (conn?.metadata?.jr) this.sendJoinResult(conn.peer, 'denied');
+ conn.close();
+ return;
+ }
const verdict = auth && typeof auth.decide === 'function' ? auth.decide(conn.peer, cloudMeta) : undefined;
if (verdict === 'deny') {
if (conn?.metadata?.jr) this.sendJoinResult(conn.peer, 'denied');
@@ -742,6 +772,7 @@ export class PeerConnection {
}
get(peers).send({ type: 'userdata', userdata: get(userdata) });
this.approveDialBack(conn.peer);
+ this.announceAutoAdmit(conn.peer, auth, cloudMeta);
}
} catch (e) {
console.error('cloud auth provider threw:', e);
@@ -803,8 +834,54 @@ export class PeerConnection {
// both invisible. This is the one function every creation site already calls —
// the four dials and the adopted inbound conn — so one listener pair covers all.
conn.on('error', (/** @type {any} */ err) => noteWireError(conn.peer, 'conn-error', err?.type ?? err));
+ // F-3: a peer that died without closing its channel (a killed process, a crashed tab)
+ // leaves the conn "open" — a ghost in Users. Two watches close it ourselves, and the
+ // close runs the ordinary reconnect window, which redials a live peer and drops a gone
+ // one: ICE held `disconnected` past the grace (ICE took ~15 s to say so in the crash
+ // probe), and the selected pair's consent checks going unanswered (peerDrop.linkSilence).
+ /** @type {ReturnType | null} */
+ let iceDropTimer = null;
+ /** @type {ReturnType | null} */
+ let silenceTimer = null;
+ const closeSilent = (/** @type {string} */ why) => {
+ if (!conn.open) return;
+ log('warn', 'net', 'link silent — closing it so the peer is redialled or dropped', { peer: conn.peer, why });
+ this.silentDrops.add(conn.peer);
+ try { conn.close(); } catch (e) { /* already dead */ }
+ };
conn.on('iceStateChanged', (/** @type {any} */ state) => {
if (state === 'failed' || state === 'closed') noteWireError(conn.peer, 'ice-' + state);
+ if (state === 'disconnected') {
+ if (iceDropTimer) clearTimeout(iceDropTimer);
+ iceDropTimer = setTimeout(() => {
+ iceDropTimer = null;
+ if (conn.peerConnection?.iceConnectionState === 'disconnected') closeSilent('ice-disconnected');
+ }, ICE_DROP_GRACE_MS);
+ } else if (iceDropTimer) {
+ clearTimeout(iceDropTimer);
+ iceDropTimer = null;
+ }
+ });
+ const watchSilence = () => {
+ if (silenceTimer) return;
+ /** @type {any} */
+ let seen = null;
+ silenceTimer = setInterval(async () => {
+ const pc = conn.peerConnection;
+ if (!conn.open || !pc || typeof pc.getStats !== 'function') return;
+ try {
+ const verdict = linkSilence(seen, consentCounters(await pc.getStats()), Date.now());
+ seen = verdict.state;
+ if (verdict.silent) closeSilent('consent-unanswered');
+ } catch (e) { /* stats unavailable: the ICE watch still stands */ }
+ }, LINK_POLL_MS);
+ };
+ if (conn.open) watchSilence();
+ conn.on('open', watchSilence);
+ conn.on('close', () => {
+ if (iceDropTimer) clearTimeout(iceDropTimer);
+ if (silenceTimer) clearInterval(silenceTimer);
+ silenceTimer = null;
});
// The dispatch chain itself, called from the guarded handler below. Naming it is
// what lets a try/catch wrap 440 lines without re-indenting any of them.
@@ -1838,6 +1915,7 @@ export class PeerConnection {
checkLocks();
return;
}
+ this.lastLeaveAt = Date.now();
if (this.gracefulLeft.delete(peerId)) {
this.finalizeDisconnect(peerId, false);
return;
@@ -1890,6 +1968,8 @@ export class PeerConnection {
conn.on('open', () => {
log('info', 'net', 'reconnected', { peer: peerId });
this.reconnecting.delete(peerId);
+ this.unavailableStrikes.delete(peerId);
+ this.silentDrops.delete(peerId);
peers.update((value) => value);
this.sendHandshake(conn, peerId, true, this.peer.id);
});
@@ -1900,6 +1980,65 @@ export class PeerConnection {
}, delay);
}
+ // F-5: an AUTOMATIC admission is never silent. A toast AND a notification-history entry
+ // that keeps its buttons: Turn off (when the provider offers it — e.g. "Allow guests to
+ // join automatically") and Remove. The provider says why; without one, a generic line.
+ /** @param {string} peerId @param {any} auth @param {any} cloudMeta */
+ announceAutoAdmit(peerId, auth, cloudMeta) {
+ let provided = null;
+ try {
+ provided = typeof auth?.admitNotice === 'function' ? auth.admitNotice(peerId, cloudMeta) : null;
+ } catch (e) {
+ console.error('cloud auth provider admitNotice threw:', e);
+ }
+ const notice = composeAdmitNotice(peerId, provided);
+ /** @type {{label: string, action: () => void}[]} */
+ const actions = [];
+ if (notice.turnOff) {
+ const turnOff = notice.turnOff;
+ actions.push({ label: notice.turnOffLabel, action: () => { try { turnOff(); } catch (e) { console.error('turnOff threw:', e); } } });
+ }
+ actions.push({ label: 'Remove', action: () => this.removeAdmittedPeer(peerId) });
+ log('info', 'net', 'peer admitted automatically', { peer: peerId });
+ showLastingToast(notice.text, actions);
+ }
+
+ // F-5: Remove from the auto-admit notice. Local to this host: drop them from the roster,
+ // close our link, and refuse their re-dials for the rest of the session. (The mesh-wide
+ // kick with a block list is roadmap 55 A9; this is the host's own door.)
+ /** @param {string} peerId */
+ removeAdmittedPeer(peerId) {
+ this.removedPeers.add(peerId);
+ this.gracefulLeft.add(peerId); // their close is expected — no redial
+ const roster = /** @type {any[]} */ (get(userdata)).filter((/** @type {any} */ e) => e[0] !== peerId);
+ userdata.set(roster);
+ if (this.openedPeers.has(peerId)) this.finalizeDisconnect(peerId, true);
+ else {
+ const conn = this.connections[peerId];
+ delete this.connections[peerId];
+ try { conn?.close(); } catch (e) { /* never opened */ }
+ }
+ this.broadcast({ type: 'userdata', userdata: get(userdata) });
+ showToast(shortPeer(peerId) + ' was removed. They cannot rejoin while this tab stays open.');
+ }
+
+ // F-3: the signaling server answered "no such peer" to a redial. The dial it named
+ // stays un-open (peerjs does not close it), so clear it — the next check redials at
+ // once instead of waiting out the dial grace — and after a few answers in a row stop
+ // waiting: the peer is gone, not blipping.
+ /** @param {string} peerId */
+ noteUnavailable(peerId) {
+ const strikes = (this.unavailableStrikes.get(peerId) ?? 0) + 1;
+ this.unavailableStrikes.set(peerId, strikes);
+ const dead = this.connections[peerId];
+ if (dead && !dead.open) {
+ delete this.connections[peerId];
+ try { dead.close(); } catch (e) { /* never opened */ }
+ }
+ log('info', 'net', 'redial: peer not on the server', { peer: peerId, strikes });
+ if (strikes >= strikesNeeded(this.silentDrops.has(peerId))) this.finalizeDisconnect(peerId, true);
+ }
+
// The peer is really gone: full per-peer teardown (self-heal — a relayed
// 'disconnected' never reaches the last peer in a 2-peer session, so voice
// nodes / VR hands / flow cursors / env presets used to leak, 172). With
@@ -1909,6 +2048,9 @@ export class PeerConnection {
/** @param {string} peerId @param {boolean} relay */
finalizeDisconnect(peerId, relay) {
this.reconnecting.delete(peerId);
+ this.unavailableStrikes.delete(peerId);
+ this.silentDrops.delete(peerId);
+ this.lastLeaveAt = Date.now();
const conn = this.connections[peerId];
if (conn) {
delete this.connections[peerId];
@@ -1945,6 +2087,9 @@ export class PeerConnection {
// reconnect redials our still-registered peer id and every attempt lands
// here as a fresh approval request
this.broadcast({ type: 'disconnected', peerId: this.peer.id });
+ this.lastLeaveAt = Date.now(); // F-3: the closes below make peer-level noise
+ this.unavailableStrikes.clear();
+ this.silentDrops.clear();
dropPeerUnderlays(null); // 60 Q5: every peer's shared reference images go with the session
this.reconnecting.clear(); // pending retry timers see a cleared map and no-op
this.gracefulLeft.clear();
diff --git a/src/lib/projectManifest.js b/src/lib/projectManifest.js
index bbaf31bae..0b5e6a3a6 100644
--- a/src/lib/projectManifest.js
+++ b/src/lib/projectManifest.js
@@ -293,6 +293,32 @@ function normalizeDeleted(rows) {
/** how many deletions the log remembers */
export const DELETED_LOG_CAP = 200;
+/**
+ * fix-live (plans review #6): A NEVER-SHARED FILE'S DELETION STAYS ON THIS MACHINE. A
+ * `localOnly` row carries the file's name, folder path and thumbnail, and the log
+ * replicates whole, so deleting a private file announced it to the session (R22 round 36
+ * "known and left"). The row stays in OUR log — the bin restores from it — and is withheld
+ * at the send boundary (`outboundManifest`); `keepLocalOnlyDeleted` is the receive half, so a
+ * peer's newer document (which can never carry those rows) does not strand our bin.
+ * @param {any} doc @returns {any} the document without `localOnly` deletion rows
+ */
+export function withoutLocalOnlyDeleted(doc) {
+ if (!doc?.deleted?.some((/** @type {any} */ r) => r?.localOnly)) return doc;
+ const deleted = doc.deleted.filter((/** @type {any} */ r) => !r?.localOnly);
+ if (deleted.length) return { ...doc, deleted };
+ const { deleted: _gone, ...rest } = doc;
+ return rest;
+}
+
+/** the receive half: `base` (the winning side's log) plus OUR `localOnly` rows it lacks
+ * @param {any[]} base @param {any[]} mine @returns {any[]} */
+export function keepLocalOnlyDeleted(base, mine) {
+ const ours = (mine ?? []).filter((/** @type {any} */ r) => r?.localOnly);
+ if (!ours.length) return normalizeDeleted(base);
+ const have = new Set((base ?? []).map(logKeyOf));
+ return normalizeDeleted([...(base ?? []), ...ours.filter((/** @type {any} */ r) => !have.has(logKeyOf(r)))]);
+}
+
/** R22 round 2: a tombstone map, with every stamp coerced to a positive number. A key
* with no usable stamp is dropped rather than kept as a permanent veto nobody can lift.
* @param {any} data @returns {{items: Record, folders: Record}} */
@@ -1055,6 +1081,8 @@ export function mergeManifests(local, remote) {
);
doc.scenes = folded.scenes;
doc.renames = folded.renames;
+ // fix-live: their log cannot hold our never-shared deletions — keep them (the bin's rows)
+ if (remoteNewer) doc.deleted = keepLocalOnlyDeleted(theirs.deleted, mine.deleted);
const out = normalizeManifest(doc);
return { doc: out, clashes, clashDetails, pointerMoves, senderLacks: senderLacking(out, theirs) };
}
@@ -1288,15 +1316,14 @@ export function outboundManifest(doc) {
};
// the host — and a solo user, who is one — publishes its project whole
if (get(sessionHost) === null)
- return withRenames(hidden.size ? { ...doc, scenes: drop(doc.scenes ?? {}) } : doc, () => true);
+ return withoutLocalOnlyDeleted(withRenames(hidden.size ? { ...doc, scenes: drop(doc.scenes ?? {}) } : doc, () => true));
const allowed = new Set([...sessionSceneNames, ...openedScenes]);
/** @type {Record} */
const scenes = {};
for (const [name, entry] of Object.entries(doc.scenes ?? {}))
if (allowed.has(name)) scenes[name] = entry;
- return withRenames(
- { ...doc, scenes: drop(scenes), name: renamedThisSession ? doc.name : '' },
- (n) => allowed.has(n)
+ return withoutLocalOnlyDeleted(
+ withRenames({ ...doc, scenes: drop(scenes), name: renamedThisSession ? doc.name : '' }, (n) => allowed.has(n))
);
}
@@ -1448,10 +1475,12 @@ export function applyRemoteManifest(data) {
const { doc: merged, clashes, clashDetails, pointerMoves } = mergeManifests(mine, doc);
surfaceMerge(clashes, pointerMoves, clashDetails);
if (sameContent(merged, mine)) return false;
- if (sameContent(merged, doc)) {
+ // fix-live: compared WITHOUT our private deletion rows — they never travel, so a document
+ // that is otherwise the union is still "theirs" (keep their stamp), with our rows kept
+ if (sameContent(withoutLocalOnlyDeleted(merged), doc)) {
// their document already IS the union: keep their stamp so the mesh stays on one
// number, which is what makes the common case indistinguishable from the old code
- projectManifest.set(doc);
+ projectManifest.set(normalizeManifest({ ...doc, ...(merged.deleted ? { deleted: merged.deleted } : {}) }));
void persist();
} else {
// above BOTH: the sender must not be able to win the next comparison with the very
diff --git a/src/stores/appStore.js b/src/stores/appStore.js
index 7d589411c..e6d3c0e03 100644
--- a/src/stores/appStore.js
+++ b/src/stores/appStore.js
@@ -762,14 +762,51 @@ export function pushNotification(text, kind = 'info') {
const entry = { id: `n${Date.now()}_${++_notifId}`, text, ts: Date.now(), kind };
notifications.update((list) => [...list, entry].slice(-50));
notificationsUnread.update((c) => c + 1);
+ return entry.id;
+}
+
+/** F-5 (fix-live): the buttons a notification-history entry KEEPS until one is pressed,
+ * keyed by entry id. Runtime only — a function cannot be persisted, so after a reload the
+ * entry is plain text again (the decision it offered belonged to that session anyway).
+ * @type {import('svelte/store').Writable void}[]>>} */
+export const notificationActions = writable({});
+
+/**
+ * F-5: a decision toast whose buttons ALSO live on its notification-history entry, so a
+ * notice missed while the toast was up can still be acted on from the bell. Pressing a
+ * button in either place answers both (the history entry loses its buttons).
+ * @param {string} message @param {{label: string, action: () => void}[]} actions
+ * @returns {string} the history entry id
+ */
+export function showLastingToast(message, actions) {
+ /** @type {string} */
+ let id = '';
+ const answer = () => notificationActions.update((m) => {
+ if (!(id in m)) return m;
+ const next = { ...m };
+ delete next[id];
+ return next;
+ });
+ const wrapped = actions.map((a) => ({ ...a, action: () => { answer(); a.action(); } }));
+ toastStore.update((toast) => [...toast, { text: message, actions: wrapped }]);
+ id = pushNotification(message, 'action') || '';
+ if (id) notificationActions.update((m) => ({ ...m, [id]: wrapped }));
+ return id;
}
/**
* Plain string = 3s info toast. Pass `actions` ([{label, action}]) for a
* sticky decision toast (15s) with buttons.
+ * fix-live F-2: `opts.keep` = the buttons ALSO stay on the notification-history entry until one
+ * is pressed (a decision someone may only see later — a role offer). See showLastingToast.
* @param {string} message @param {{label: string, action: () => void}[]=} actions
+ * @param {{keep?: boolean}=} opts
*/
-export function showToast(message, actions) {
+export function showToast(message, actions, opts) {
+ if (actions?.length && opts?.keep) {
+ showLastingToast(message, actions);
+ return;
+ }
toastStore.update((toast) => {
// U-3: collapse duplicate plain-string toasts so a repeated message can't
// spam the stack (action toasts are always distinct, never deduped)
diff --git a/tests/e2e/fix-live.test.cjs b/tests/e2e/fix-live.test.cjs
new file mode 100644
index 000000000..8153c94d9
--- /dev/null
+++ b/tests/e2e/fix-live.test.cjs
@@ -0,0 +1,260 @@
+// fix-live (roadmap 55 "Fixes for 54"): three live production bugs, each on real peers.
+// §1 F-1 a VIEWER's AI tool call is refused, never `created`, and nothing reaches the host
+// §1b a viewer's getproject / getasset / getmatpreset pass the capability floor (plans review #5)
+// §1c F-4 AI-made objects are static unless asked for physics; a dynamic one falls, a static one stays
+// §2c F-2 an action toast kept for later ({keep}) has a working Accept in Notifications
+// §1d deleting a never-shared file does not announce it through the deleted log
+// §2 F-5 an AUTOMATIC admission gives the host a toast + a history entry with Turn off /
+// Remove, and the Connect pill shows the standing setting's badge
+// §3 F-3 a graceful leave prunes the peer at once and toasts no "Connection error"
+// §5 a plugin's Users peer-menu item (the cloud's Report…) runs with the peer id
+// §4 F-3 a peer whose renderer CRASHES (no goodbye, no SCTP close — the T5 wire agent that
+// exited) leaves Users within seconds, again with no "Connection error"
+const h = require('./helpers.cjs');
+
+const EVIDENCE = process.env.FIX_LIVE_EVIDENCE || '';
+
+/** @param {any} page */
+const childCount = (page) =>
+ page.evaluate(() => new Promise((r) => window.__stores.objectsGroup.subscribe((g) => r(g?.children.length ?? -1))()));
+/** @param {any} page @param {string} id */
+const inRoster = (page, id) =>
+ page.evaluate((pid) => new Promise((r) => window.__stores.userdata.subscribe((u) => r(u.some((e) => e[0] === pid)))()), id);
+/** @param {any} page @param {string} id */
+const opened = (page, id) =>
+ page.evaluate((pid) => new Promise((r) => window.__stores.peers.subscribe((p) => r(!!p?.openedPeers?.has(pid)))()), id);
+/** every notification text on the page @param {any} page @returns {Promise} */
+const notes = (page) =>
+ page.evaluate(() => new Promise((r) => window.__stores.notifications.subscribe((n) => r(n.map((x) => x.text)))()));
+const NOISE = /Connection error|Peer is unreachable/;
+
+h.run(async () => {
+ const browser = await h.launch();
+ const A = await h.setupPage(browser, 'A');
+ const B = await h.setupPage(browser, 'B');
+ const C = await h.setupPage(browser, 'C');
+ await h.connect(B, A);
+ await h.eventually(() => opened(A.page, B.id), (v) => v === true, 'B joined A');
+
+ // ── §1 F-1: B is a viewer (what a cloud build's Approve gives) ─────────────────────────
+ await B.page.evaluate(() => {
+ const p = window.__stores.peers;
+ /** @type {any} */ let me = null;
+ p.subscribe((v) => (me = v))();
+ window.__stores.cloudHooks.rolesInfo.set({
+ myId: me?.peer?.id || 'me', myRole: 'viewer', amAdmin: false,
+ order: ['viewer', 'editor', 'admin'], roleOf: () => 'viewer', setRole: () => {}
+ });
+ });
+ const before = await childCount(A.page);
+ const created = await B.page.evaluate(() =>
+ window.__stores.aiTools.executeAiTool('create_objects', { objects: [{ kind: 'primitive', primitive: 'Box', name: 'viewer-box' }] })
+ );
+ h.check(!!created?.error && created.viewOnly === true && !('created' in created), 'F-1: a viewer create_objects is refused, not `created` (' + JSON.stringify(created).slice(0, 120) + ')');
+ h.check(/view-only/.test(created?.error ?? ''), 'F-1: the refusal says view-only');
+ const upd = await B.page.evaluate(() => window.__stores.aiTools.executeAiTool('update_objects', { updates: [{ uuid: 'x', color: '#f00' }] }));
+ h.check(upd?.viewOnly === true, 'F-1: update_objects refused too');
+ const list = await B.page.evaluate(() => window.__stores.aiTools.executeAiTool('list_scene', {}));
+ h.check(!list?.error, 'F-1: list_scene still works for a viewer');
+ h.check((await childCount(B.page)) === before && (await childCount(A.page)) === before, 'F-1: nothing was made, here or on the host');
+ await B.page.evaluate(() => window.__stores.cloudHooks.rolesInfo.set(null));
+ const edited = await B.page.evaluate(() =>
+ window.__stores.aiTools.executeAiTool('create_objects', { objects: [{ kind: 'primitive', primitive: 'Box', name: 'editor-box' }] })
+ );
+ h.check(Array.isArray(edited?.created), 'F-1 counterfactual: the same call as an editor creates');
+ await h.eventually(() => childCount(A.page), (n) => n === before + 1, 'F-1 counterfactual: and it reaches the host');
+
+ // ── §1c F-4: AI-made objects stay put unless asked for physics ──────────────────────
+ const made = await A.page.evaluate(() =>
+ window.__stores.aiTools.executeAiTool('create_objects', {
+ objects: [
+ { kind: 'primitive', primitive: 'Box', name: 'ai-wall', position: [3, 2, 0] },
+ { kind: 'primitive', primitive: 'Box', name: 'ai-ball', position: [-3, 2, 0], physics: 'dynamic', mass: 3 }
+ ]
+ })
+ );
+ const [wallId, ballId] = (made?.created ?? []).map((/** @type {any} */ c) => c.uuid);
+ /** @param {any} page @param {string} uuid */
+ const physOf = (page, uuid) =>
+ page.evaluate((u) => new Promise((r) => window.__stores.objectsGroup.subscribe((g) => {
+ const o = g?.getObjectByProperty('uuid', u);
+ r(o ? { mode: o.userData.physics?.mode ?? null, mass: o.userData.physics?.mass ?? null, y: o.position.y } : null);
+ })()), uuid);
+ h.check((await physOf(A.page, wallId))?.mode === 'static', 'F-4: an AI-made box is static by default (not dynamic, mass 1)');
+ h.check((await physOf(A.page, ballId))?.mode === 'dynamic' && (await physOf(A.page, ballId))?.mass === 3, 'F-4: asked for physics, it is dynamic with the asked mass');
+ await h.eventually(() => physOf(B.page, wallId), (p) => p?.mode === 'static', 'F-4: the static default replicates to the other peer');
+ await h.eventually(() => physOf(B.page, ballId), (p) => p?.mode === 'dynamic', 'F-4: and the dynamic one too');
+ await A.page.evaluate(() => window.__stores.physics.toggleSimulation()); // rapier warms up inside
+ await h.eventually(() => physOf(A.page, ballId), (p) => p && p.y < 1.2, 'F-4: in a sim the dynamic one falls', 20000);
+ h.check(Math.abs(((await physOf(A.page, wallId))?.y ?? 0) - 2) < 0.01, 'F-4: …while the static wall stays where it was built');
+ await A.page.evaluate(() => window.__stores.physics.stopSimulation());
+ await A.page.waitForTimeout(500);
+
+ // ── §1b floor: a VIEWER's read-only requests reach the host (plans review #5) ─────────
+ // A gates B as a cloud roles plugin would for a viewer (every non-floor type from B dropped)
+ await A.page.evaluate((bid) => {
+ const w = /** @type {any} */ (window);
+ w.__asked = [];
+ w.__stores.cloudHooks.setCapabilityProvider((pid, t) => { if (pid !== bid) return true; w.__asked.push(t); return false; });
+ w.__stores.projectManifest.setProjectName('Floor project');
+ }, B.id);
+ await B.page.evaluate((aid) => {
+ const w = /** @type {any} */ (window);
+ w.__got = [];
+ const note = (d) => { if (d?.type === 'batch' && Array.isArray(d.items)) d.items.forEach(note); else if (d?.type) w.__got.push(d.type); };
+ /** @type {any} */ let p = null;
+ w.__stores.peers.subscribe((v) => (p = v))();
+ for (const list of Object.values(p.peer.connections || {})) for (const c of /** @type {any[]} */ (list)) c.on('data', note);
+ const conn = p.connections[aid];
+ conn.send({ type: 'getproject', sender: p.peer.id });
+ conn.send({ type: 'getasset', hash: '0'.repeat(64) });
+ conn.send({ type: 'getmatpreset', ref: 'no-such-preset' });
+ conn.send({ type: 'color', uuid: 'no-such-object', color: '#ff0000' }); // a mutation: must stay dropped
+ }, A.id);
+ await h.eventually(() => B.page.evaluate(() => (/** @type {any} */ (window)).__got), (g) => g.includes('manifest') && g.includes('assetmissing'), 'floor: a viewer\'s getproject and getasset are answered (manifest, assetmissing)');
+ const asked = await A.page.evaluate(() => (/** @type {any} */ (window)).__asked);
+ h.check(!asked.includes('getproject') && !asked.includes('getasset') && !asked.includes('getmatpreset'), 'floor: getproject / getasset / getmatpreset never reach the viewer gate (' + JSON.stringify(asked) + ')');
+ h.check(asked.includes('color'), 'floor counterfactual: the viewer\'s mutation is still gated');
+ await A.page.evaluate(() => window.__stores.cloudHooks.setCapabilityProvider(null));
+
+ // ── §1d private deletions: a never-shared file's deletion stays on B (plans review #6) ──
+ /** @param {any} page @returns {Promise} */
+ const deletedLog = (page) => page.evaluate(() => new Promise((r) => window.__stores.projectManifest.projectManifest.subscribe((m) => r(m.deleted ?? []))()));
+ const privId = await B.page.evaluate(async () => {
+ const s = /** @type {any} */ (window).__stores;
+ const item = await s.explorer.addItemFromBytes(new TextEncoder().encode('my secret notes').buffer, 'diary-secret.txt');
+ s.sharedLibrary.deleteItemsToBin([item.id]);
+ return item.id;
+ });
+ await h.eventually(() => deletedLog(B.page), (rows) => rows.some((r) => r.name === 'diary-secret.txt' && r.localOnly), 'private delete: B\'s own bin keeps the row (localOnly)');
+ await A.page.waitForTimeout(2500);
+ h.check(!(await deletedLog(A.page)).some((r) => r.name === 'diary-secret.txt' || r.id === privId), 'private delete: the host never hears the private file\'s name, folder or thumbnail');
+ // a NEWER document from the host must not strand B's bin
+ await A.page.evaluate(() => window.__stores.projectManifest.setProjectName('Floor project 2'));
+ await h.eventually(() => B.page.evaluate(() => new Promise((r) => window.__stores.projectManifest.projectManifest.subscribe((m) => r(m.name))())), (n) => n === 'Floor project 2', 'private delete: (setup) the host\'s newer manifest reached B');
+ h.check((await deletedLog(B.page)).some((r) => r.name === 'diary-secret.txt'), 'private delete: …and B still has its private row (the bin can restore it)');
+ h.check(!(await deletedLog(A.page)).some((r) => r.name === 'diary-secret.txt'), 'private delete: …while the host still does not');
+
+ // ── §2 F-5: A's session auto-admits (the cloud rooms plugin's "Allow guests") ─────────
+ await A.page.evaluate((cid) => {
+ const w = /** @type {any} */ (window);
+ w.__turnedOff = 0;
+ w.__stores.cloudHooks.setAuthProvider({
+ decide: (/** @type {string} */ pid) => (pid === cid ? 'admit' : undefined),
+ admitNotice: (/** @type {string} */ pid) => ({
+ text: pid.toUpperCase() + ' joined as a guest (view-only) because Allow guests to join automatically is on.',
+ turnOff: () => { w.__turnedOff++; w.__stores.cloudHooks.setConnectBadge(null); }
+ })
+ });
+ w.__stores.cloudHooks.setConnectBadge({ text: 'Public · guests join', title: 'Your listed room lets guests in without approval' });
+ }, C.id);
+ h.check(await A.page.locator('#connect-setting-badge').isVisible(), 'F-5: the Connect pill shows the badge');
+ h.check(((await A.page.locator('#connect-setting-badge').textContent()) || '').includes('Public · guests join'), 'F-5: badge text');
+ // C dials; no approval card is pressed
+ await C.page.locator('input[placeholder="Enter peer ID to connect"]').fill(A.id);
+ await C.page.getByRole('button', { name: 'Connect', exact: true }).click();
+ await h.eventually(() => opened(A.page, C.id), (v) => v === true, 'F-5: C was admitted automatically', 30000);
+ const admitText = C.id.toUpperCase() + ' joined as a guest';
+ await h.eventually(() => notes(A.page), (n) => n.some((t) => t.includes(admitText)), 'F-5: a notification-history entry on the host');
+ h.check(await A.page.getByText(admitText).first().isVisible(), 'F-5: and a toast on the host');
+ h.check((await A.page.evaluate(() => new Promise((r) => window.__stores.pendingApprovals.subscribe((v) => r(v.length))()))) === 0, 'F-5: no approval card was needed');
+ // the bell keeps the buttons
+ await A.page.evaluate(() => window.__stores.toastStore.set([]));
+ await A.page.locator('#notif-bell').click();
+ const row = A.page.locator('.notif-row', { hasText: admitText });
+ h.check(await row.getByRole('button', { name: 'Turn off' }).isVisible(), 'F-5: the history entry keeps Turn off');
+ h.check(await row.getByRole('button', { name: 'Remove' }).isVisible(), 'F-5: and Remove');
+ await row.getByRole('button', { name: 'Turn off' }).click();
+ h.check((await A.page.evaluate(() => (/** @type {any} */ (window)).__turnedOff)) === 1, 'F-5: Turn off ran the provider switch');
+ await h.eventually(() => A.page.locator('#connect-setting-badge').count(), (n) => n === 0, 'F-5: the badge goes with the setting');
+ h.check((await row.getByRole('button').count()) === 0, 'F-5: an answered entry loses its buttons');
+ await A.page.evaluate(() => window.__stores.notificationCenterOpen.set(false));
+
+ // ── §2c F-2: an offer seen later keeps a working Accept in Notifications ─────────────
+ await A.page.evaluate(() => {
+ const w = /** @type {any} */ (window);
+ w.__offerAccepted = 0;
+ w.__stores.showToast('You have been offered the ADMIN role by AB12C. Accept?', [
+ { label: 'Accept', action: () => w.__offerAccepted++ },
+ { label: 'Decline', action: () => {} }
+ ], { keep: true });
+ w.__stores.toastStore.set([]); // the toast expired; only the bell is left
+ });
+ await A.page.locator('#notif-bell').click();
+ const offer = A.page.locator('.notif-row', { hasText: 'offered the ADMIN role' });
+ // a DOM click (the button's own handler): an overlay toast that lands over the bell panel on a
+ // loaded run (the auto-quality notice) must not make the row "not clickable"
+ await offer.getByRole('button', { name: 'Accept' }).dispatchEvent('click', {}, { timeout: 5000 });
+ h.check((await A.page.evaluate(() => (/** @type {any} */ (window)).__offerAccepted)) === 1, 'F-2: Accept in Notifications runs the offer\'s action');
+ h.check((await offer.getByRole('button').count()) === 0, 'F-2: and the answered offer loses its buttons');
+ await A.page.evaluate(() => window.__stores.notificationCenterOpen.set(false));
+
+ // ── §3 F-3: C leaves gracefully ───────────────────────────────────────────────────────
+ const noiseBefore = (await notes(A.page)).filter((t) => NOISE.test(t)).length;
+ let t0 = Date.now();
+ // read the instance FIRST: leaveSession updates `peers`, so calling it inside the subscribe
+ // callback re-enters that callback forever (a store ping-pong that filled 700 MB of log)
+ await C.page.evaluate(() => {
+ /** @type {any} */ let p = null;
+ window.__stores.peers.subscribe((v) => (p = v))();
+ p.leaveSession();
+ });
+ await h.eventually(() => inRoster(A.page, C.id), (v) => v === false, 'F-3: a graceful leave leaves Users', 8000);
+ const graceMs = Date.now() - t0;
+ await A.page.waitForTimeout(3000);
+ h.check((await notes(A.page)).filter((t) => NOISE.test(t)).length === noiseBefore, 'F-3: a graceful leave toasts no Connection error');
+
+ // ── §2b F-5 Remove: C comes back (still admitted), the host removes them ─────────────
+ await C.page.locator('input[placeholder="Enter peer ID to connect"]').fill(A.id);
+ await C.page.getByRole('button', { name: 'Connect', exact: true }).click();
+ await h.eventually(() => opened(A.page, C.id), (v) => v === true, 'F-5: C admitted again', 30000);
+ // the admission TOAST's own Remove (a DOM click: an overlay toast can sit on it; scoped, because
+ // a bare "Remove" also matches hidden buttons elsewhere in the app)
+ await A.page.locator('.tp-toast', { hasText: admitText }).last().getByRole('button', { name: 'Remove' }).dispatchEvent('click', {}, { timeout: 10000 });
+ await h.eventually(() => inRoster(A.page, C.id), (v) => v === false, 'F-5: Remove drops C from Users', 8000);
+ await h.eventually(() => opened(A.page, C.id), (v) => v === false, 'F-5: and closes the link');
+ await C.page.waitForTimeout(16000); // past C's own reconnect window
+ await C.page.locator('input[placeholder="Enter peer ID to connect"]').fill(A.id).catch(() => {});
+ await C.page.getByRole('button', { name: 'Connect', exact: true }).click().catch(() => {});
+ await A.page.waitForTimeout(8000);
+ h.check((await opened(A.page, C.id)) === false && (await inRoster(A.page, C.id)) === false, 'F-5: a removed peer cannot dial back in');
+
+ // ── §5 reports seam: a plugin's "Report…" in the Users peer menu ─────────────────────
+ await A.page.evaluate(() => {
+ const w = /** @type {any} */ (window);
+ w.__reported = null;
+ w.__stores.cloudHooks.setPeerMenuItems([{ id: 'report', label: 'Report…', run: (pid, info) => (w.__reported = { pid, name: info?.name ?? null }) }]);
+ w.__stores.peersPanelOpen.set(true);
+ });
+ await A.page.locator('.peer-watch').first().dispatchEvent('contextmenu', { clientX: 400, clientY: 120, button: 2 }, { timeout: 10000 });
+ // DOM events, not the mouse: a loaded remote browser stalled Playwright's right-click twice in
+ // four runs. pointerdown first — the menu ignores a click it did not see pressed (guardLift)
+ const item = A.page.getByRole('menuitem', { name: 'Report…' });
+ await item.dispatchEvent('pointerdown', {}, { timeout: 5000 });
+ await item.dispatchEvent('click');
+ const reported = await A.page.evaluate(() => (/** @type {any} */ (window)).__reported);
+ h.check(reported?.pid === B.id && typeof reported?.name === 'string', 'reports: the peer menu runs the plugin item with the peer id + roster name (' + JSON.stringify(reported) + ')');
+ await A.page.evaluate(() => window.__stores.peersPanelOpen.set(false));
+
+ // ── §4 F-3: B's renderer crashes — no goodbye, no channel close ──────────────────────
+ h.check(await inRoster(A.page, B.id), 'F-3: B is still in Users before the crash');
+ const noise2 = (await notes(A.page)).filter((t) => NOISE.test(t)).length;
+ const cdp = await B.ctx.newCDPSession(B.page);
+ t0 = Date.now();
+ // NOT awaited: the target dies before it can answer, so the promise never settles (a 35 min hang)
+ cdp.send('Page.crash').catch(() => {});
+ // only the LOWER id redials (B5: two redials are glare), and only a redial can hear "not on the
+ // server" — so the bound depends on which side A is (base vs fix: crash-probe in the evidence)
+ // measured on a local slot: 17.7 s when A redials, 27.2 s when it waits out the window; the base
+ // never pruned it (90 s probe). Silence is seen at ~9 s; the rest is the B5 reconnect window.
+ const bound = A.id < B.id ? 25000 : 35000;
+ await h.eventually(() => inRoster(A.page, B.id), (v) => v === false, `F-3: a crashed peer leaves Users within ${bound / 1000} s`, bound);
+ const crashMs = Date.now() - t0;
+ await A.page.waitForTimeout(3000);
+ const tail = (await notes(A.page)).filter((t) => NOISE.test(t));
+ h.check(tail.length === noise2, 'F-3: the drop toasts no Connection error (' + tail.slice(noise2).join(' | ') + ')');
+ console.log(` measured: graceful leave pruned in ${graceMs} ms, crashed peer pruned in ${crashMs} ms (A ${A.id} vs B ${B.id}: ${A.id < B.id ? 'A redials' : 'B would redial'})`);
+ if (EVIDENCE) require('fs').writeFileSync(EVIDENCE, JSON.stringify({ graceMs, crashMs, aId: A.id, bId: B.id, aRedials: A.id < B.id }, null, 2));
+
+ await h.finish(browser);
+});
diff --git a/tests/unit/admitNotice.test.js b/tests/unit/admitNotice.test.js
new file mode 100644
index 000000000..f1b3f5f5a
--- /dev/null
+++ b/tests/unit/admitNotice.test.js
@@ -0,0 +1,64 @@
+import { describe, it, expect } from 'vitest';
+import { get } from 'svelte/store';
+import { composeAdmitNotice, shortPeer } from '../../src/lib/admitNotice.js';
+import { connectBadge, setConnectBadge, peerMenuItems, setPeerMenuItems } from '../../src/lib/cloudHooks.js';
+
+// F-5: an automatic admission is never silent, and a standing "guests join" setting is visible.
+describe('F-5 auto-admit notice', () => {
+ it('uses the provider text and its Turn off', () => {
+ let off = 0;
+ const n = composeAdmitNotice('e6f54', {
+ text: 'E6F54 joined as a guest (view-only) because Allow guests to join automatically is on.',
+ turnOff: () => off++
+ });
+ expect(n.text).toMatch(/joined as a guest/);
+ expect(n.turnOffLabel).toBe('Turn off');
+ n.turnOff?.();
+ expect(off).toBe(1);
+ });
+ it('falls back to a generic line with no Turn off (no provider, a throwing one, junk)', () => {
+ for (const provided of [null, undefined, 'x', 42, { text: ' ' }, { turnOff: 'nope' }]) {
+ const n = composeAdmitNotice('a1b2c', provided);
+ expect(n.text).toBe('A1B2C joined automatically — your session settings let them in without approval.');
+ expect(n.turnOff).toBeNull();
+ }
+ });
+ it('caps what the provider can put on screen', () => {
+ const n = composeAdmitNotice('a1b2c', { text: 'x'.repeat(500), turnOffLabel: 'y'.repeat(80), turnOff() {} });
+ expect(n.text.length).toBe(200);
+ expect(n.turnOffLabel.length).toBe(24);
+ });
+ it('shortPeer is the six-char upper-case id the other join toasts use', () => {
+ expect(shortPeer('e6f54')).toBe('E6F54');
+ expect(shortPeer(/** @type {any} */ (undefined))).toBe('');
+ });
+});
+
+describe('F-5 Connect pill badge seam', () => {
+ it('normalises, caps and clears', () => {
+ setConnectBadge({ text: ' Public · guests join ', title: 'Anyone can join', onClick: () => {} });
+ const b = get(connectBadge);
+ expect(b?.text).toBe('Public · guests join');
+ expect(b?.title).toBe('Anyone can join');
+ expect(typeof b?.onClick).toBe('function');
+ setConnectBadge({ text: 'z'.repeat(99) });
+ expect(get(connectBadge)?.text.length).toBe(32);
+ expect(get(connectBadge)?.onClick).toBeNull();
+ for (const v of [null, {}, { text: '' }, 'Public']) {
+ setConnectBadge(v);
+ expect(get(connectBadge)).toBeNull();
+ }
+ });
+});
+
+describe('reports: Users peer menu seam', () => {
+ it('keeps only runnable, labelled items, capped', () => {
+ const run = () => {};
+ setPeerMenuItems([{ id: 'report', label: ' Report… ', run }, { label: 'no run' }, { label: '', run }, null]);
+ expect(get(peerMenuItems)).toEqual([{ id: 'report', label: 'Report…', run }]);
+ setPeerMenuItems(Array.from({ length: 12 }, (_, n) => ({ label: 'x' + n, run })));
+ expect(get(peerMenuItems).length).toBe(8);
+ setPeerMenuItems('junk');
+ expect(get(peerMenuItems)).toEqual([]);
+ });
+});
diff --git a/tests/unit/aiPhysicsDefault.test.js b/tests/unit/aiPhysicsDefault.test.js
new file mode 100644
index 000000000..df2fbbf68
--- /dev/null
+++ b/tests/unit/aiPhysicsDefault.test.js
@@ -0,0 +1,26 @@
+import { describe, it, expect } from 'vitest';
+import { aiCreatePhysics } from '../../src/lib/ai/aiPhysicsDefault.js';
+
+// fix-live F-4 (test flight T8): AI-made objects stay put in Play unless asked for physics
+describe('F-4 AI create physics default', () => {
+ const stamped = { mode: 'dynamic', mass: 1 }; // what the Add-menu builder gives a Box
+ it('turns the builder\'s dynamic default to static when nothing was asked', () => {
+ expect(aiCreatePhysics({ kind: 'primitive', primitive: 'Box' }, stamped)).toEqual({ mode: 'static' });
+ expect(aiCreatePhysics({ physics: 'static' }, stamped)).toEqual({ mode: 'static' });
+ });
+ it('leaves objects the builder did not make dynamic alone (lights, groups, terrain)', () => {
+ expect(aiCreatePhysics({ kind: 'light' }, undefined)).toBeNull();
+ expect(aiCreatePhysics({}, { mode: 'auto' })).toBeNull();
+ });
+ it('keeps dynamic when asked, with the asked mass', () => {
+ expect(aiCreatePhysics({ physics: 'dynamic' }, stamped)).toBeNull(); // already dynamic, mass 1
+ expect(aiCreatePhysics({ physics: 'dynamic', mass: 4 }, stamped)).toEqual({ mode: 'dynamic', mass: 4 });
+ expect(aiCreatePhysics({ mass: 2.5 }, stamped)).toEqual({ mode: 'dynamic', mass: 2.5 });
+ expect(aiCreatePhysics({ physics: 'dynamic' }, undefined)).toEqual({ mode: 'dynamic', mass: 1 });
+ });
+ it('ignores a junk mass and caps a huge one', () => {
+ expect(aiCreatePhysics({ mass: -1 }, stamped)).toEqual({ mode: 'static' });
+ expect(aiCreatePhysics({ mass: 'heavy' }, stamped)).toEqual({ mode: 'static' });
+ expect(aiCreatePhysics({ mass: 1e9 }, stamped)).toEqual({ mode: 'dynamic', mass: 10000 });
+ });
+});
diff --git a/tests/unit/cloudHooksFloor.test.js b/tests/unit/cloudHooksFloor.test.js
index 6b95c1920..bd9c1951a 100644
--- a/tests/unit/cloudHooksFloor.test.js
+++ b/tests/unit/cloudHooksFloor.test.js
@@ -41,6 +41,26 @@ describe('cloudHooks ALWAYS_ALLOWED floor', () => {
for (const t of sent) expect(canApply('v', t), t).toBe(true);
});
+ it('lets a viewer request the project manifest, an asset and a material preset (fix-live)', () => {
+ setCapabilityProvider(() => false);
+ for (const t of ['getproject', 'getasset', 'getmatpreset']) expect(canApply('v', t), t).toBe(true);
+ });
+
+ it('keeps those REPLIES gateable (fix-live)', () => {
+ setCapabilityProvider(() => false);
+ for (const t of ['manifest', 'assetfile', 'assetstart', 'assetchunk', 'assetmissing', 'matpresetpart']) expect(canApply('v', t), t).toBe(false);
+ });
+
+ it('has every get* REQUEST the dispatcher answers on the floor, bar the recorded few', () => {
+ const src = readFileSync(new URL('../../src/lib/peerHandler.svelte.js', import.meta.url), 'utf8');
+ const answered = [...new Set([...src.matchAll(/data\.type == '(get[a-z]+)'/g)].map((m) => m[1]))];
+ expect(answered.length).toBeGreaterThan(10);
+ // still off the floor, recorded for the orchestrator (fix-live STATUS): each needs its own look
+ const KNOWN_OFF = ['gettransport', 'getgame', 'getkitentities', 'getpatch'];
+ setCapabilityProvider(() => false);
+ for (const t of answered) expect(canApply('v', t), t).toBe(!KNOWN_OFF.includes(t));
+ });
+
it('allows everything with no provider (the OSS build)', () => {
expect(canApply('v', 'seqs')).toBe(true);
});
diff --git a/tests/unit/lastingToast.test.js b/tests/unit/lastingToast.test.js
new file mode 100644
index 000000000..13a11e3fb
--- /dev/null
+++ b/tests/unit/lastingToast.test.js
@@ -0,0 +1,35 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { get } from 'svelte/store';
+import { showToast, showLastingToast, toastStore, notifications, notificationActions } from '../../src/stores/appStore.js';
+
+// fix-live F-2 + F-5: a decision toast whose buttons survive in the notification history
+describe('lasting toasts', () => {
+ beforeEach(() => {
+ toastStore.set([]);
+ notifications.set([]);
+ notificationActions.set({});
+ });
+ it('showToast(…, {keep}) keeps working buttons on the history entry (the role offer)', () => {
+ let accepted = 0;
+ showToast('You have been offered the ADMIN role by AB12C. Accept?', [{ label: 'Accept', action: () => accepted++ }], { keep: true });
+ const entry = get(notifications).at(-1);
+ const acts = get(notificationActions)[entry.id];
+ expect(acts?.map((a) => a.label)).toEqual(['Accept']);
+ acts[0].action();
+ expect(accepted).toBe(1);
+ expect(get(notificationActions)[entry.id]).toBeUndefined(); // answered: the buttons go
+ });
+ it('pressing the TOAST button also clears the history buttons', () => {
+ let n = 0;
+ const id = showLastingToast('X joined automatically', [{ label: 'Remove', action: () => n++ }]);
+ const card = /** @type {any} */ (get(toastStore).at(-1));
+ card.actions[0].action();
+ expect(n).toBe(1);
+ expect(get(notificationActions)[id]).toBeUndefined();
+ });
+ it('a plain action toast (no keep) is unchanged: history text only', () => {
+ showToast('Undo?', [{ label: 'Undo', action: () => {} }]);
+ expect(Object.keys(get(notificationActions))).toEqual([]);
+ expect(get(notifications).at(-1).kind).toBe('action');
+ });
+});
diff --git a/tests/unit/localOnlyDeleted.test.js b/tests/unit/localOnlyDeleted.test.js
new file mode 100644
index 000000000..ff9592345
--- /dev/null
+++ b/tests/unit/localOnlyDeleted.test.js
@@ -0,0 +1,42 @@
+import { describe, it, expect } from 'vitest';
+import { withoutLocalOnlyDeleted, keepLocalOnlyDeleted, mergeManifests, outboundManifest } from '../../src/lib/projectManifest.js';
+
+// fix-live (plans review #6): a never-shared file's deletion row never leaves this machine
+const priv = { hash: 'h-private', id: 'r1', name: 'diary.png', kind: 'image', at: 100, localOnly: true, folderId: 'f1', path: ['Secret'], thumb: 'data:x' };
+const pub = { hash: 'h-shared', id: 'r2', name: 'tree.glb', kind: 'model', at: 200 };
+
+describe('localOnly deletion rows', () => {
+ it('are withheld from what is sent', () => {
+ expect(withoutLocalOnlyDeleted({ changedAt: 1, deleted: [priv, pub] }).deleted).toEqual([pub]);
+ expect('deleted' in withoutLocalOnlyDeleted({ changedAt: 1, deleted: [priv] })).toBe(false);
+ const doc = { changedAt: 1, deleted: [pub] };
+ expect(withoutLocalOnlyDeleted(doc)).toBe(doc); // nothing private: untouched
+ });
+ it('outboundManifest (the one send boundary) carries no private row, name, path or thumbnail', () => {
+ const out = outboundManifest({ changedAt: 5, scenes: {}, assets: [], deleted: [priv, pub] });
+ const wire = JSON.stringify(out);
+ expect(wire).not.toContain('diary.png');
+ expect(wire).not.toContain('Secret');
+ expect(wire).not.toContain('h-private');
+ expect(wire).toContain('tree.glb');
+ });
+ it('survive a NEWER peer document, which can never carry them (the bin is not stranded)', () => {
+ const theirs = { hash: 'h-theirs', id: 'r3', name: 'rock.glb', kind: 'model', at: 300 };
+ const { doc } = mergeManifests(
+ { changedAt: 10, scenes: {}, assets: [], deleted: [priv, pub] },
+ { changedAt: 20, scenes: {}, assets: [], deleted: [pub, theirs] }
+ );
+ expect(doc.deleted?.map((r) => r.hash)).toEqual(['h-private', 'h-shared', 'h-theirs']);
+ });
+ it('a SHARED row the newer side dropped stays dropped (latest-wins is unchanged for shared rows)', () => {
+ const { doc } = mergeManifests(
+ { changedAt: 10, scenes: {}, assets: [], deleted: [pub] },
+ { changedAt: 20, scenes: {}, assets: [], deleted: [] }
+ );
+ expect(doc.deleted ?? []).toEqual([]);
+ });
+ it('keepLocalOnlyDeleted lets the winning side keep a key it already has', () => {
+ const shared = { ...priv, localOnly: undefined, at: 150 };
+ expect(keepLocalOnlyDeleted([shared], [priv]).map((r) => r.at)).toEqual([150]);
+ });
+});
diff --git a/tests/unit/peerDrop.test.js b/tests/unit/peerDrop.test.js
new file mode 100644
index 000000000..781e6b478
--- /dev/null
+++ b/tests/unit/peerDrop.test.js
@@ -0,0 +1,74 @@
+import { describe, it, expect } from 'vitest';
+import {
+ quietPeerError,
+ unavailablePeerId,
+ LEAVE_QUIET_MS,
+ ICE_DROP_GRACE_MS,
+ UNAVAILABLE_STRIKES,
+ strikesNeeded,
+ consentCounters,
+ linkSilence,
+ LINK_SILENT_MS
+} from '../../src/lib/peerDrop.js';
+
+// F-3: a dropped peer leaves Users within seconds and a leave never toasts "Connection error".
+describe('F-3 peer drop policy', () => {
+ const now = 1_000_000;
+ it('keeps leave/drop symptoms off the screen while a leave or redial is in flight', () => {
+ for (const type of ['webrtc', 'disconnected', 'negotiation-failed', 'connection-closed']) {
+ expect(quietPeerError(type, { lastLeaveAt: now - 1000, reconnecting: 0, now })).toBe(true);
+ expect(quietPeerError(type, { lastLeaveAt: 0, reconnecting: 1, now })).toBe(true);
+ }
+ });
+ it('still shows them when nothing is leaving (a real failure is news)', () => {
+ expect(quietPeerError('webrtc', { lastLeaveAt: now - LEAVE_QUIET_MS - 1, reconnecting: 0, now })).toBe(false);
+ expect(quietPeerError('webrtc', { lastLeaveAt: 0, reconnecting: 0, now })).toBe(false);
+ });
+ it('never hides errors that are not leave symptoms', () => {
+ for (const type of ['browser-incompatible', 'invalid-id', 'ssl-unavailable', 'unavailable-id'])
+ expect(quietPeerError(type, { lastLeaveAt: now, reconnecting: 3, now })).toBe(false);
+ });
+ it('reads the id from the END of the peerjs message (the old regex answered "Could")', () => {
+ expect(unavailablePeerId('Could not connect to peer e6f54')).toBe('e6f54');
+ expect(unavailablePeerId('Could not connect to peer a1b2c ')).toBe('a1b2c');
+ expect(unavailablePeerId('something else')).toBe('');
+ expect(unavailablePeerId(undefined)).toBe('');
+ });
+ it('a link closed for silence needs one "not on the server" answer, a closed channel three', () => {
+ expect(strikesNeeded(true)).toBe(1);
+ expect(strikesNeeded(false)).toBe(UNAVAILABLE_STRIKES);
+ expect(UNAVAILABLE_STRIKES).toBeGreaterThan(1);
+ });
+ it('drops a silent peer well inside the reconnect window', () => {
+ // grace + the first redial (0.5 s) + one EXPIRE answer (~5 s) < the 15.5 s window
+ expect(ICE_DROP_GRACE_MS + 500 + 5000).toBeLessThan(15500);
+ });
+
+ it('reads the SELECTED pair\'s consent counters (transport first, then a selected/nominated pair)', () => {
+ const viaTransport = new Map([
+ ['T', { type: 'transport', selectedCandidatePairId: 'P2' }],
+ ['P1', { id: 'P1', type: 'candidate-pair', requestsSent: 1, responsesReceived: 1 }],
+ ['P2', { id: 'P2', type: 'candidate-pair', requestsSent: 9, responsesReceived: 8 }]
+ ]);
+ expect(consentCounters(viaTransport)).toEqual({ requestsSent: 9, responsesReceived: 8 });
+ const nominated = [{ id: 'P', type: 'candidate-pair', nominated: true, state: 'succeeded', requestsSent: 3, responsesReceived: 3 }];
+ expect(consentCounters(nominated)).toEqual({ requestsSent: 3, responsesReceived: 3 });
+ expect(consentCounters([{ type: 'candidate-pair', state: 'waiting' }])).toBeNull();
+ expect(consentCounters(null)).toBeNull();
+ });
+ it('a link is silent only when we kept asking and nothing answered for LINK_SILENT_MS', () => {
+ let v = linkSilence(null, { requestsSent: 10, responsesReceived: 10 }, 0);
+ expect(v.silent).toBe(false);
+ v = linkSilence(v.state, { requestsSent: 12, responsesReceived: 10 }, LINK_SILENT_MS - 1);
+ expect(v.silent).toBe(false);
+ const dead = linkSilence(v.state, { requestsSent: 13, responsesReceived: 10 }, LINK_SILENT_MS);
+ expect(dead.silent).toBe(true);
+ // an answer resets the clock
+ const alive = linkSilence(v.state, { requestsSent: 13, responsesReceived: 11 }, LINK_SILENT_MS + 5000);
+ expect(alive.silent).toBe(false);
+ // no requests going out (an idle pair libwebrtc stopped pinging) is never "silent"
+ expect(linkSilence({ responses: 5, requests: 5, at: 0 }, { requestsSent: 5, responsesReceived: 5 }, 60000).silent).toBe(false);
+ // no stats = never silent
+ expect(linkSilence({ responses: 5, requests: 5, at: 0 }, null, 60000).silent).toBe(false);
+ });
+});
diff --git a/tests/unit/viewerGate.test.js b/tests/unit/viewerGate.test.js
new file mode 100644
index 000000000..30b51187c
--- /dev/null
+++ b/tests/unit/viewerGate.test.js
@@ -0,0 +1,26 @@
+import { describe, it, expect } from 'vitest';
+import { viewerToolRefusal, VIEWER_REFUSAL } from '../../src/lib/ai/viewerGate.js';
+
+describe('F-1 viewer AI tool gate', () => {
+ it('lets editors (and the no-roles OSS build) run every tool', () => {
+ for (const name of ['create_objects', 'update_objects', 'clear_scene', 'set_physics'])
+ expect(viewerToolRefusal(name, false)).toBeNull();
+ });
+ it('refuses every scene-changing tool for a viewer, never a fake success', () => {
+ for (const name of ['create_objects', 'update_objects', 'delete_objects', 'group_objects', 'clear_scene',
+ 'create_flow_nodes', 'update_flow_nodes', 'set_physics', 'create_joints', 'create_behaviour',
+ 'edit_behaviour', 'generate_mesh', 'some_future_tool']) {
+ const r = viewerToolRefusal(name, true);
+ expect(r).toEqual({ error: VIEWER_REFUSAL, viewOnly: true });
+ expect(r).not.toHaveProperty('created');
+ }
+ });
+ it('keeps reading the scene and local playback open to viewers', () => {
+ expect(viewerToolRefusal('list_scene', true)).toBeNull();
+ expect(viewerToolRefusal('control_simulation', true)).toBeNull();
+ });
+ it('says why and what to do', () => {
+ expect(VIEWER_REFUSAL).toMatch(/view-only/);
+ expect(VIEWER_REFUSAL).toMatch(/edit access/);
+ });
+});