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
+42 -15
View File
@@ -82,7 +82,10 @@ function serverSupports(cap) { return Array.isArray(serverCapabilities) && serve
// in heartbeats so the host's popup UI can show "Solo" instead of silently // in heartbeats so the host's popup UI can show "Solo" instead of silently
// appearing un-ACK'd. // appearing un-ACK'd.
let hcmDesynced = false; let hcmDesynced = false;
function amHost() { return !!peerId && hostPeerId === peerId; } // Co-Host: peerIds allowed to drive in host-only (always includes the owner).
let controllers = [];
function amHost() { return !!peerId && hostPeerId === peerId; } // owner: can toggle mode / promote
function amController() { return amHost() || (!!peerId && controllers.includes(peerId)); } // can drive the room
// Room-moving actions a guest may not initiate while in host-only mode. // Room-moving actions a guest may not initiate while in host-only mode.
const HOST_ONLY_GATED_ACTIONS = [ const HOST_ONLY_GATED_ACTIONS = [
EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK,
@@ -160,6 +163,7 @@ function ensureState() {
// Host Control Mode: restore role/mode/capabilities from persisted room. // Host Control Mode: restore role/mode/capabilities from persisted room.
controlMode = currentRoom.controlMode || CONTROL_MODES.EVERYONE; controlMode = currentRoom.controlMode || CONTROL_MODES.EVERYONE;
hostPeerId = currentRoom.hostPeerId || null; hostPeerId = currentRoom.hostPeerId || null;
controllers = Array.isArray(currentRoom.controllers) ? currentRoom.controllers : [];
serverCapabilities = Array.isArray(currentRoom.capabilities) ? currentRoom.capabilities : []; serverCapabilities = Array.isArray(currentRoom.capabilities) ? currentRoom.capabilities : [];
} }
if (data.hcmDesynced !== undefined) hcmDesynced = data.hcmDesynced; if (data.hcmDesynced !== undefined) hcmDesynced = data.hcmDesynced;
@@ -477,6 +481,7 @@ async function leaveRoomAfterIdleGrace(reason) {
currentRoom = null; currentRoom = null;
controlMode = CONTROL_MODES.EVERYONE; controlMode = CONTROL_MODES.EVERYONE;
hostPeerId = null; hostPeerId = null;
controllers = [];
serverCapabilities = []; serverCapabilities = [];
hcmDesynced = false; hcmDesynced = false;
// Notify content.js/popup BEFORE currentTabId is cleared so they can reset // Notify content.js/popup BEFORE currentTabId is cleared so they can reset
@@ -692,7 +697,7 @@ async function connect() {
// mislabel us as "Solo" to peers and (in content) keep us ignoring host commands // mislabel us as "Solo" to peers and (in content) keep us ignoring host commands
// after the reason to is gone. Call after any controlMode/hostPeerId change. // after the reason to is gone. Call after any controlMode/hostPeerId change.
function hcmEnforceDesyncInvariant() { function hcmEnforceDesyncInvariant() {
if (hcmDesynced && !(controlMode === CONTROL_MODES.HOST_ONLY && !amHost())) { if (hcmDesynced && !(controlMode === CONTROL_MODES.HOST_ONLY && !amController())) {
hcmDesynced = false; hcmDesynced = false;
if (storageInitialized) chrome.storage.session.set({ hcmDesynced: false }); if (storageInitialized) chrome.storage.session.set({ hcmDesynced: false });
} }
@@ -701,7 +706,7 @@ function hcmEnforceDesyncInvariant() {
function broadcastControlMode() { function broadcastControlMode() {
// Notify popup (role badge / host toggle) and the active content tab // Notify popup (role badge / host toggle) and the active content tab
// (so it can enable/disable the host-only guest gate). // (so it can enable/disable the host-only guest gate).
const payload = { type: 'CONTROL_MODE', controlMode, hostPeerId, amHost: amHost(), hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL) }; const payload = { type: 'CONTROL_MODE', controlMode, hostPeerId, controllers, amHost: amHost(), amController: amController(), hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL), coHostSupported: serverSupports(CAPABILITIES.CO_HOST) };
chrome.runtime.sendMessage(payload).catch(() => {}); chrome.runtime.sendMessage(payload).catch(() => {});
if (currentTabId) { if (currentTabId) {
const tabId = parseInt(currentTabId); const tabId = parseInt(currentTabId);
@@ -943,16 +948,16 @@ function handleServerEvent(event, data) {
return; return;
} }
// Host Control Mode (receiver-side backstop): in host-only mode, ignore // Host Control Mode (receiver-side backstop): in host-only mode, ignore
// room-moving events from any non-host peer. The server already drops these, // room-moving events from any non-controller. The server already drops these,
// so this covers old/buggy/modified clients that slipped through. // so this covers old/buggy/modified clients that slipped through.
// Defensive: require a known hostPeerId — if the server ever sends host-only // Defensive: require a known hostPeerId — if the server ever sends host-only
// without a host (state inconsistency), gate-everyone would lock the host // without a host (state inconsistency), gate-everyone would lock the owner
// out of their own room (L-6). // out of their own room (L-6).
if (controlMode === CONTROL_MODES.HOST_ONLY && if (controlMode === CONTROL_MODES.HOST_ONLY &&
hostPeerId && hostPeerId &&
HOST_ONLY_GATED_ACTIONS.includes(event) && HOST_ONLY_GATED_ACTIONS.includes(event) &&
data.senderId && data.senderId !== hostPeerId) { data.senderId && data.senderId !== hostPeerId && !controllers.includes(data.senderId)) {
addLog(`Ignored ${event} from non-host ${data.senderId} (host-only)`, 'warn'); addLog(`Ignored ${event} from non-controller ${data.senderId} (host-only)`, 'warn');
return; return;
} }
switch (event) { switch (event) {
@@ -961,6 +966,7 @@ function handleServerEvent(event, data) {
// Host Control Mode: adopt room role/mode on (re)join. // Host Control Mode: adopt room role/mode on (re)join.
controlMode = data.controlMode || CONTROL_MODES.EVERYONE; controlMode = data.controlMode || CONTROL_MODES.EVERYONE;
hostPeerId = data.hostPeerId || null; hostPeerId = data.hostPeerId || null;
controllers = Array.isArray(data.controllers) ? data.controllers : [];
serverCapabilities = Array.isArray(data.capabilities) ? data.capabilities : []; serverCapabilities = Array.isArray(data.capabilities) ? data.capabilities : [];
hcmEnforceDesyncInvariant(); hcmEnforceDesyncInvariant();
broadcastControlMode(); broadcastControlMode();
@@ -1025,13 +1031,15 @@ function handleServerEvent(event, data) {
// Host Control Mode changed (toggle or host-leave fallback). // Host Control Mode changed (toggle or host-leave fallback).
controlMode = data.controlMode || CONTROL_MODES.EVERYONE; controlMode = data.controlMode || CONTROL_MODES.EVERYONE;
hostPeerId = data.hostPeerId || null; hostPeerId = data.hostPeerId || null;
controllers = Array.isArray(data.controllers) ? data.controllers : [];
hcmEnforceDesyncInvariant(); hcmEnforceDesyncInvariant();
if (currentRoom) { if (currentRoom) {
currentRoom.controlMode = controlMode; currentRoom.controlMode = controlMode;
currentRoom.hostPeerId = hostPeerId; currentRoom.hostPeerId = hostPeerId;
currentRoom.controllers = controllers;
if (storageInitialized) chrome.storage.session.set({ currentRoom }); if (storageInitialized) chrome.storage.session.set({ currentRoom });
} }
addLog(`Control mode: ${controlMode}${amHost() ? ' (you are host)' : ''}`, 'info'); addLog(`Control mode: ${controlMode}${amHost() ? ' (you are owner)' : (amController() ? ' (you are controller)' : '')}`, 'info');
broadcastControlMode(); broadcastControlMode();
break; break;
case EVENTS.ROOM_LIST: case EVENTS.ROOM_LIST:
@@ -1596,6 +1604,7 @@ function leaveOldRoomIfSwitching(newRoomId) {
currentRoom = null; currentRoom = null;
controlMode = CONTROL_MODES.EVERYONE; controlMode = CONTROL_MODES.EVERYONE;
hostPeerId = null; hostPeerId = null;
controllers = [];
serverCapabilities = []; serverCapabilities = [];
hcmDesynced = false; hcmDesynced = false;
// Notify content.js/popup so they drop any guest-side HCM state from the // Notify content.js/popup so they drop any guest-side HCM state from the
@@ -1715,8 +1724,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
ping: currentPingMs, ping: currentPingMs,
controlMode, controlMode,
hostPeerId, hostPeerId,
controllers,
amHost: amHost(), amHost: amHost(),
hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL) amController: amController(),
hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL),
coHostSupported: serverSupports(CAPABILITIES.CO_HOST)
}); });
} else if (message.type === 'SET_CONTROL_MODE') { } else if (message.type === 'SET_CONTROL_MODE') {
// Popup (host) toggles the room control mode. Server validates host authority // Popup (host) toggles the room control mode. Server validates host authority
@@ -1732,12 +1744,26 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} }
emit(EVENTS.SET_CONTROL_MODE, { controlMode: mode }); emit(EVENTS.SET_CONTROL_MODE, { controlMode: mode });
sendResponse({ status: 'ok' }); sendResponse({ status: 'ok' });
} else if (message.type === 'SET_PEER_ROLE') {
// Popup (owner) promotes/demotes a peer to/from controller. Server validates
// owner authority and broadcasts CONTROL_MODE back, refreshing all clients.
const targetPeerId = typeof message.peerId === 'string' ? message.peerId : null;
if (!targetPeerId) {
sendResponse({ status: 'invalid' });
return;
}
if (!amHost()) {
sendResponse({ status: 'not_owner' });
return;
}
emit(EVENTS.SET_PEER_ROLE, { peerId: targetPeerId, controller: message.controller === true });
sendResponse({ status: 'ok' });
} else if (message.type === 'GET_CONTROL_MODE') { } else if (message.type === 'GET_CONTROL_MODE') {
// content.js asks for current mode/role on (re)injection. Include the // content.js asks for current mode/role on (re)injection. Include the
// persisted desync state so a page reload re-adopts it — otherwise a fresh // persisted desync state so a page reload re-adopts it — otherwise a fresh
// content script would start synced while background keeps relaying us as // content script would start synced while background keeps relaying us as
// "Solo" to the host (stale-badge split-brain). // "Solo" to the host (stale-badge split-brain).
sendResponse({ controlMode, hostPeerId, amHost: amHost(), desynced: hcmDesynced, hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL) }); sendResponse({ controlMode, hostPeerId, controllers, amHost: amHost(), amController: amController(), desynced: hcmDesynced, hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL), coHostSupported: serverSupports(CAPABILITIES.CO_HOST) });
} else if (message.type === 'REQUEST_HOST_SYNC') { } else if (message.type === 'REQUEST_HOST_SYNC') {
// content.js resync: hand back the host's extrapolated current position. // content.js resync: hand back the host's extrapolated current position.
sendResponse({ target: getHostSyncTarget() }); sendResponse({ target: getHostSyncTarget() });
@@ -1782,6 +1808,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
currentRoom = null; currentRoom = null;
controlMode = CONTROL_MODES.EVERYONE; controlMode = CONTROL_MODES.EVERYONE;
hostPeerId = null; hostPeerId = null;
controllers = [];
serverCapabilities = []; serverCapabilities = [];
hcmDesynced = false; hcmDesynced = false;
// Notify content.js/popup BEFORE currentTabId is cleared so they drop any // Notify content.js/popup BEFORE currentTabId is cleared so they drop any
@@ -1908,12 +1935,12 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}); });
} else if (message.type === 'CONTENT_EVENT') { } else if (message.type === 'CONTENT_EVENT') {
const processEvent = () => { const processEvent = () => {
// Host Control Mode (sender-side): a guest in host-only mode must not // Host Control Mode (sender-side): a non-controller in host-only mode must
// drive the room. Don't broadcast; hand the action back to content.js so // not drive the room. Don't broadcast; hand the action back to content.js so
// it can snap the local player back (and, in step 4, offer desync). // it can snap the local player back / offer desync.
// Defensive: require a known hostPeerId (L-6) — otherwise the actual // Defensive: require a known hostPeerId (L-6) — otherwise the actual
// host would gate themselves if state ever becomes inconsistent. // owner would gate themselves if state ever becomes inconsistent.
if (controlMode === CONTROL_MODES.HOST_ONLY && hostPeerId && !amHost() && if (controlMode === CONTROL_MODES.HOST_ONLY && hostPeerId && !amController() &&
HOST_ONLY_GATED_ACTIONS.includes(message.action)) { HOST_ONLY_GATED_ACTIONS.includes(message.action)) {
addLog(`Host-only: blocked local ${message.action} (you are a guest)`, 'warn'); addLog(`Host-only: blocked local ${message.action} (you are a guest)`, 'warn');
if (sender.tab && sender.tab.id) { if (sender.tab && sender.tab.id) {
+8 -8
View File
@@ -108,7 +108,7 @@
// we handle the *local* UX: snap back to the host's position, or — if the user // 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. // really wants to — let them go solo (desync) with a resync escape hatch.
let hcmControlMode = 'everyone'; // mirror of room control mode 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 hcmHostPeerId = null; // last known host peerId (room/host identity)
let hcmDesynced = false; // user chose to go solo let hcmDesynced = false; // user chose to go solo
let hcmSnapBackCooldownUntil = 0; // suppress re-trigger right after a snap-back let hcmSnapBackCooldownUntil = 0; // suppress re-trigger right after a snap-back
@@ -138,7 +138,7 @@
document.addEventListener('pointerdown', _hcmGesture, { capture: true, passive: true }); document.addEventListener('pointerdown', _hcmGesture, { capture: true, passive: true });
function hcmIsGuestGated() { function hcmIsGuestGated() {
return hcmControlMode === 'host-only' && !hcmAmHost; return hcmControlMode === 'host-only' && !hcmAmController;
} }
// EC-9 intent classifier: only a *clearly deliberate* guest action triggers the // 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 // 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. // (join race, EC-5) — otherwise we'd miss the dialog/snap-back.
hcmControlMode = 'host-only'; hcmControlMode = 'host-only';
hcmAmHost = false; hcmAmController = false;
if (hcmDesynced) return; // already solo, nothing to do if (hcmDesynced) return; // already solo, nothing to do
const intent = hcmClassifyIntent(); const intent = hcmClassifyIntent();
@@ -847,7 +847,7 @@
const wasGated = hcmIsGuestGated(); const wasGated = hcmIsGuestGated();
const prevHostPeerId = hcmHostPeerId; const prevHostPeerId = hcmHostPeerId;
hcmControlMode = message.controlMode || 'everyone'; hcmControlMode = message.controlMode || 'everyone';
hcmAmHost = !!message.amHost; hcmAmController = !!message.amController;
hcmHostPeerId = message.hostPeerId || null; hcmHostPeerId = message.hostPeerId || null;
// Reset guest-side state when leaving the gated state, OR when the // Reset guest-side state when leaving the gated state, OR when the
// host identity changes (room switch, host-leave fallback, missed // host identity changes (room switch, host-leave fallback, missed
@@ -1488,13 +1488,13 @@
chrome.runtime.sendMessage({ type: 'GET_CONTROL_MODE' }, (res) => { chrome.runtime.sendMessage({ type: 'GET_CONTROL_MODE' }, (res) => {
if (chrome.runtime.lastError || !res) return; if (chrome.runtime.lastError || !res) return;
hcmControlMode = res.controlMode || 'everyone'; hcmControlMode = res.controlMode || 'everyone';
hcmAmHost = !!res.amHost; hcmAmController = !!res.amController;
hcmHostPeerId = res.hostPeerId || null; hcmHostPeerId = res.hostPeerId || null;
// Re-adopt persisted desync after a page reload so we don't start synced // 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). // 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 // Only when we're actually a gated guest — never adopt a stale flag as a
// host or in 'everyone' mode (would self-label "Solo" / ignore commands). // controller or in 'everyone' mode (would self-label "Solo" / ignore commands).
if (res.desynced && res.controlMode === 'host-only' && !res.amHost && !hcmDesynced) { if (res.desynced && res.controlMode === 'host-only' && !res.amController && !hcmDesynced) {
hcmDesynced = true; hcmDesynced = true;
hcmShowBadge(); hcmShowBadge();
} }