From 82ef896e8dae678406ce378cc9cd49df44ffd0c4 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 24 Oct 2026 10:56:34 +0300 Subject: [PATCH 01/13] fix-live F-1: a viewer's AI tool call is refused, never reported as created Viewers' creations stay local and their edits are dropped by every receiver, so executeAiTool answered { created } for work nobody else would see (test flight T1). Scene-changing tools now return a view-only refusal; list_scene and local simulation playback still work. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/ai/tools.js | 5 +++++ src/lib/ai/viewerGate.js | 24 ++++++++++++++++++++++++ tests/unit/viewerGate.test.js | 26 ++++++++++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 src/lib/ai/viewerGate.js create mode 100644 tests/unit/viewerGate.test.js diff --git a/src/lib/ai/tools.js b/src/lib/ai/tools.js index 6d31f429a..fbdc1b399 100644 --- a/src/lib/ai/tools.js +++ b/src/lib/ai/tools.js @@ -12,6 +12,8 @@ import { import { setObjectColor, switchMaterialType, setMaterialParam } from '$lib/materialsHandler'; import { notifyExternalMove } from '$lib/flowRuntime'; import { meshGenReady } from './meshProviders.js'; +import { viewerToolRefusal } from './viewerGate.js'; +import { isViewer } from '$lib/objectPermissions'; import { graphOf, SCENE_GRAPH } from '../../stores/flowStore'; import { sceneJoints } from '$lib/joints'; import { graphToText } from '$lib/graphText'; @@ -562,6 +564,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': 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/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/); + }); +}); From 10f8e30dccda6b6e09850e866a54fdd04e8f28e2 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 24 Oct 2026 10:56:34 +0300 Subject: [PATCH 02/13] fix-live F-3: dropped peers leave Users in seconds; a leave never toasts Connection error - ICE held 'disconnected' past 5 s closes the conn ourselves, so a peer that died without closing its channel goes through the reconnect window instead of staying a ghost (test flight T5). - A redial answered 'peer-unavailable' is not toasted; three answers in a row end the window at once. The id is read from the end of the message (the old regex answered 'Could'). - Peer-level webrtc/disconnected noise right after a leave, drop or redial is logged, not toasted. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/peerDrop.js | 52 +++++++++++++++++++++++++++++ src/lib/peerHandler.svelte.js | 61 ++++++++++++++++++++++++++++++++++- tests/unit/peerDrop.test.js | 38 ++++++++++++++++++++++ 3 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 src/lib/peerDrop.js create mode 100644 tests/unit/peerDrop.test.js diff --git a/src/lib/peerDrop.js b/src/lib/peerDrop.js new file mode 100644 index 000000000..ea28003a7 --- /dev/null +++ b/src/lib/peerDrop.js @@ -0,0 +1,52 @@ +// 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 = 5000; + +/** "this id is not on the signaling server" answers, while redialling a dropped peer, + * before we stop waiting for them. Three spans ~3.5 s of the backoff (0.5 + 1 + 2 s): long + * enough for a signaling blip on their side, short enough that a dead peer is gone in + * seconds. */ +export const UNAVAILABLE_STRIKES = 3; + +/** 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..789e4a071 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -35,6 +35,7 @@ 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 { ICE_DROP_GRACE_MS, UNAVAILABLE_STRIKES, quietPeerError, unavailablePeerId } from '$lib/peerDrop'; import { noteWire } from '$lib/sceneBudget'; import { applyAnnotation, applyAnnotationsSnapshot, sendAnnotations } from '$lib/annotationsHandler'; import { applyPing } from '$lib/ping'; @@ -285,6 +286,12 @@ 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(); /** @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 +609,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 +626,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); } @@ -803,9 +820,28 @@ 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)); + /** @type {ReturnType | null} */ + let iceDropTimer = null; conn.on('iceStateChanged', (/** @type {any} */ state) => { if (state === 'failed' || state === 'closed') noteWireError(conn.peer, 'ice-' + state); + // F-3: a peer that died without closing its channel leaves ICE `disconnected` + // and the conn "open" for 30 s or forever — a ghost in Users. Held past the + // grace, close it ourselves: the close runs the ordinary reconnect window, which + // redials a live peer and drops a gone one. + if (state === 'disconnected') { + if (iceDropTimer) clearTimeout(iceDropTimer); + iceDropTimer = setTimeout(() => { + iceDropTimer = null; + if (conn.peerConnection?.iceConnectionState !== 'disconnected' || !conn.open) return; + log('warn', 'net', 'link silent — closing it so the peer is redialled or dropped', { peer: conn.peer }); + try { conn.close(); } catch (e) { /* already dead */ } + }, ICE_DROP_GRACE_MS); + } else if (iceDropTimer) { + clearTimeout(iceDropTimer); + iceDropTimer = null; + } }); + conn.on('close', () => { if (iceDropTimer) clearTimeout(iceDropTimer); }); // 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. const dispatch = (/** @type {any} */ data) => { @@ -1838,6 +1874,7 @@ export class PeerConnection { checkLocks(); return; } + this.lastLeaveAt = Date.now(); if (this.gracefulLeft.delete(peerId)) { this.finalizeDisconnect(peerId, false); return; @@ -1890,6 +1927,7 @@ export class PeerConnection { conn.on('open', () => { log('info', 'net', 'reconnected', { peer: peerId }); this.reconnecting.delete(peerId); + this.unavailableStrikes.delete(peerId); peers.update((value) => value); this.sendHandshake(conn, peerId, true, this.peer.id); }); @@ -1900,6 +1938,23 @@ export class PeerConnection { }, delay); } + // 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 >= UNAVAILABLE_STRIKES) 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 +1964,8 @@ export class PeerConnection { /** @param {string} peerId @param {boolean} relay */ finalizeDisconnect(peerId, relay) { this.reconnecting.delete(peerId); + this.unavailableStrikes.delete(peerId); + this.lastLeaveAt = Date.now(); const conn = this.connections[peerId]; if (conn) { delete this.connections[peerId]; @@ -1945,6 +2002,8 @@ 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(); 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/tests/unit/peerDrop.test.js b/tests/unit/peerDrop.test.js new file mode 100644 index 000000000..e39e7d030 --- /dev/null +++ b/tests/unit/peerDrop.test.js @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { + quietPeerError, + unavailablePeerId, + LEAVE_QUIET_MS, + ICE_DROP_GRACE_MS, + UNAVAILABLE_STRIKES +} 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('drops a dead peer in seconds, not the old 30 s+', () => { + // ICE grace + the strikes' share of the 0.5/1/2 s backoff + const backoff = [500, 1000, 2000, 4000, 8000].slice(0, UNAVAILABLE_STRIKES).reduce((a, b) => a + b, 0); + expect(ICE_DROP_GRACE_MS + backoff).toBeLessThanOrEqual(10000); + }); +}); From 08d451d5b7a6d109e5f5adb6a5e645d72cdac15e Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 24 Oct 2026 10:58:54 +0300 Subject: [PATCH 03/13] fix-live F-5: automatic admissions are announced; a standing guests setting shows on Connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Every auto-admitted peer gives the host a toast AND a notification-history entry that keeps its buttons until answered: Remove (session-local door: roster row dropped, link closed, re-dials refused while the tab is open) and the provider's Turn off (test flight T14: a remembered 'Allow guests' admitted silently). - authProvider.admitNotice(peerId, cloudMeta) supplies the line + switch-off; cloudApi.setConnectBadge puts e.g. 'Public · guests join' on the Connect pill. - OPEN-CORE.md documents both seams. Co-Authored-By: Claude Opus 5.5 (1M context) --- OPEN-CORE.md | 12 ++++ src/components/menu/Connect.svelte | 30 +++++++++- src/components/menu/NotificationCenter.svelte | 37 ++++++++++++- src/lib/admitNotice.js | 25 +++++++++ src/lib/cloudHooks.js | 26 +++++++++ src/lib/cloudPlugin.js | 5 +- src/lib/peerHandler.svelte.js | 55 ++++++++++++++++++- src/stores/appStore.js | 30 ++++++++++ tests/unit/admitNotice.test.js | 52 ++++++++++++++++++ 9 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 src/lib/admitNotice.js create mode 100644 tests/unit/admitNotice.test.js diff --git a/OPEN-CORE.md b/OPEN-CORE.md index d8835e273..7409e7994 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,7 @@ 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") | +| `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..4f5c52276 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 && !compact} + + + {/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/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/cloudHooks.js b/src/lib/cloudHooks.js index ba14693fd..365fb14f7 100644 --- a/src/lib/cloudHooks.js +++ b/src/lib/cloudHooks.js @@ -126,6 +126,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 +417,25 @@ 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 + ); +} diff --git a/src/lib/cloudPlugin.js b/src/lib/cloudPlugin.js index e995e99d3..0795bbaa6 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 } 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,9 @@ 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), // --- 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, diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index 789e4a071..a9ccf6a07 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -35,6 +35,7 @@ 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, UNAVAILABLE_STRIKES, quietPeerError, unavailablePeerId } from '$lib/peerDrop'; import { noteWire } from '$lib/sceneBudget'; import { applyAnnotation, applyAnnotationsSnapshot, sendAnnotations } from '$lib/annotationsHandler'; @@ -116,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'; @@ -292,6 +293,9 @@ export class PeerConnection { /** F-3: peerId -> "not on the signaling server" answers while we redial them * @type {Map} */ this.unavailableStrikes = new Map(); + /** 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 @@ -729,6 +733,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'); @@ -759,6 +769,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); @@ -1938,6 +1949,48 @@ 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 diff --git a/src/stores/appStore.js b/src/stores/appStore.js index 7d589411c..18578e1df 100644 --- a/src/stores/appStore.js +++ b/src/stores/appStore.js @@ -762,6 +762,36 @@ 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; } /** diff --git a/tests/unit/admitNotice.test.js b/tests/unit/admitNotice.test.js new file mode 100644 index 000000000..47d82cde3 --- /dev/null +++ b/tests/unit/admitNotice.test.js @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { get } from 'svelte/store'; +import { composeAdmitNotice, shortPeer } from '../../src/lib/admitNotice.js'; +import { connectBadge, setConnectBadge } 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(); + } + }); +}); From 14a16ef5e415129586824fbb509d74c5244a0030 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 24 Oct 2026 11:10:05 +0300 Subject: [PATCH 04/13] fix-live F-5: the setting badge also shows on the connected chip A connected pill collapses to its chip, which is exactly when a host forgets a remembered 'guests join' setting; the badge was gated off there. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/menu/Connect.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/menu/Connect.svelte b/src/components/menu/Connect.svelte index 4f5c52276..533f8b5be 100644 --- a/src/components/menu/Connect.svelte +++ b/src/components/menu/Connect.svelte @@ -659,7 +659,7 @@ > {/if} - {#if $connectBadge && !squeezedView && !compact} + {#if $connectBadge && !squeezedView}