feat(co-host): client gates use controller-set membership (background + content)

- background: track controllers[] from ROOM_DATA/CONTROL_MODE (restore + reset on
  teardown); add amController() (owner or co-host). Both gates now key on controller
  membership (sender: !amController(); receiver: senderId not in controllers); desync
  invariant uses amController. Thread controllers/amController/coHostSupported through
  GET_STATUS/GET_CONTROL_MODE/CONTROL_MODE. New SET_PEER_ROLE message (owner→server).
- content: gate on amController instead of amHost (a controller is not a gated guest);
  reconcile/HOST_BLOCKED updated accordingly. Drop now-unused hcmAmHost.

Single-host behavior unchanged (owner is always the sole controller until they promote).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
KoalaDev
2026-06-28 03:41:23 +02:00
parent a326087ca5
commit 7c65fe80cb
2 changed files with 50 additions and 23 deletions
+8 -8
View File
@@ -108,7 +108,7 @@
// we handle the *local* UX: snap back to the host's position, or — if the user
// really wants to — let them go solo (desync) with a resync escape hatch.
let hcmControlMode = 'everyone'; // mirror of room control mode
let hcmAmHost = false; // are we the host?
let hcmAmController = false; // are we allowed to drive (owner or co-host)?
let hcmHostPeerId = null; // last known host peerId (room/host identity)
let hcmDesynced = false; // user chose to go solo
let hcmSnapBackCooldownUntil = 0; // suppress re-trigger right after a snap-back
@@ -138,7 +138,7 @@
document.addEventListener('pointerdown', _hcmGesture, { capture: true, passive: true });
function hcmIsGuestGated() {
return hcmControlMode === 'host-only' && !hcmAmHost;
return hcmControlMode === 'host-only' && !hcmAmController;
}
// EC-9 intent classifier: only a *clearly deliberate* guest action triggers the
@@ -247,7 +247,7 @@
// role/mode from it in case our CONTROL_MODE broadcast hasn't landed yet
// (join race, EC-5) — otherwise we'd miss the dialog/snap-back.
hcmControlMode = 'host-only';
hcmAmHost = false;
hcmAmController = false;
if (hcmDesynced) return; // already solo, nothing to do
const intent = hcmClassifyIntent();
@@ -847,7 +847,7 @@
const wasGated = hcmIsGuestGated();
const prevHostPeerId = hcmHostPeerId;
hcmControlMode = message.controlMode || 'everyone';
hcmAmHost = !!message.amHost;
hcmAmController = !!message.amController;
hcmHostPeerId = message.hostPeerId || null;
// Reset guest-side state when leaving the gated state, OR when the
// host identity changes (room switch, host-leave fallback, missed
@@ -1488,13 +1488,13 @@
chrome.runtime.sendMessage({ type: 'GET_CONTROL_MODE' }, (res) => {
if (chrome.runtime.lastError || !res) return;
hcmControlMode = res.controlMode || 'everyone';
hcmAmHost = !!res.amHost;
hcmAmController = !!res.amController;
hcmHostPeerId = res.hostPeerId || null;
// Re-adopt persisted desync after a page reload so we don't start synced
// while background still relays us as "Solo" to the host (split-brain).
// Only when we're actually a gated guest — never adopt a stale flag as the
// host or in 'everyone' mode (would self-label "Solo" / ignore commands).
if (res.desynced && res.controlMode === 'host-only' && !res.amHost && !hcmDesynced) {
// Only when we're actually a gated guest — never adopt a stale flag as a
// controller or in 'everyone' mode (would self-label "Solo" / ignore commands).
if (res.desynced && res.controlMode === 'host-only' && !res.amController && !hcmDesynced) {
hcmDesynced = true;
hcmShowBadge();
}