mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
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:
+42
-15
@@ -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
|
||||
// appearing un-ACK'd.
|
||||
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.
|
||||
const HOST_ONLY_GATED_ACTIONS = [
|
||||
EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK,
|
||||
@@ -160,6 +163,7 @@ function ensureState() {
|
||||
// Host Control Mode: restore role/mode/capabilities from persisted room.
|
||||
controlMode = currentRoom.controlMode || CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = currentRoom.hostPeerId || null;
|
||||
controllers = Array.isArray(currentRoom.controllers) ? currentRoom.controllers : [];
|
||||
serverCapabilities = Array.isArray(currentRoom.capabilities) ? currentRoom.capabilities : [];
|
||||
}
|
||||
if (data.hcmDesynced !== undefined) hcmDesynced = data.hcmDesynced;
|
||||
@@ -477,6 +481,7 @@ async function leaveRoomAfterIdleGrace(reason) {
|
||||
currentRoom = null;
|
||||
controlMode = CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = null;
|
||||
controllers = [];
|
||||
serverCapabilities = [];
|
||||
hcmDesynced = false;
|
||||
// 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
|
||||
// after the reason to is gone. Call after any controlMode/hostPeerId change.
|
||||
function hcmEnforceDesyncInvariant() {
|
||||
if (hcmDesynced && !(controlMode === CONTROL_MODES.HOST_ONLY && !amHost())) {
|
||||
if (hcmDesynced && !(controlMode === CONTROL_MODES.HOST_ONLY && !amController())) {
|
||||
hcmDesynced = false;
|
||||
if (storageInitialized) chrome.storage.session.set({ hcmDesynced: false });
|
||||
}
|
||||
@@ -701,7 +706,7 @@ function hcmEnforceDesyncInvariant() {
|
||||
function broadcastControlMode() {
|
||||
// Notify popup (role badge / host toggle) and the active content tab
|
||||
// (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(() => {});
|
||||
if (currentTabId) {
|
||||
const tabId = parseInt(currentTabId);
|
||||
@@ -943,16 +948,16 @@ function handleServerEvent(event, data) {
|
||||
return;
|
||||
}
|
||||
// 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.
|
||||
// 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).
|
||||
if (controlMode === CONTROL_MODES.HOST_ONLY &&
|
||||
hostPeerId &&
|
||||
HOST_ONLY_GATED_ACTIONS.includes(event) &&
|
||||
data.senderId && data.senderId !== hostPeerId) {
|
||||
addLog(`Ignored ${event} from non-host ${data.senderId} (host-only)`, 'warn');
|
||||
data.senderId && data.senderId !== hostPeerId && !controllers.includes(data.senderId)) {
|
||||
addLog(`Ignored ${event} from non-controller ${data.senderId} (host-only)`, 'warn');
|
||||
return;
|
||||
}
|
||||
switch (event) {
|
||||
@@ -961,6 +966,7 @@ function handleServerEvent(event, data) {
|
||||
// Host Control Mode: adopt room role/mode on (re)join.
|
||||
controlMode = data.controlMode || CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = data.hostPeerId || null;
|
||||
controllers = Array.isArray(data.controllers) ? data.controllers : [];
|
||||
serverCapabilities = Array.isArray(data.capabilities) ? data.capabilities : [];
|
||||
hcmEnforceDesyncInvariant();
|
||||
broadcastControlMode();
|
||||
@@ -1025,13 +1031,15 @@ function handleServerEvent(event, data) {
|
||||
// Host Control Mode changed (toggle or host-leave fallback).
|
||||
controlMode = data.controlMode || CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = data.hostPeerId || null;
|
||||
controllers = Array.isArray(data.controllers) ? data.controllers : [];
|
||||
hcmEnforceDesyncInvariant();
|
||||
if (currentRoom) {
|
||||
currentRoom.controlMode = controlMode;
|
||||
currentRoom.hostPeerId = hostPeerId;
|
||||
currentRoom.controllers = controllers;
|
||||
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();
|
||||
break;
|
||||
case EVENTS.ROOM_LIST:
|
||||
@@ -1596,6 +1604,7 @@ function leaveOldRoomIfSwitching(newRoomId) {
|
||||
currentRoom = null;
|
||||
controlMode = CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = null;
|
||||
controllers = [];
|
||||
serverCapabilities = [];
|
||||
hcmDesynced = false;
|
||||
// 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,
|
||||
controlMode,
|
||||
hostPeerId,
|
||||
controllers,
|
||||
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') {
|
||||
// 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 });
|
||||
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') {
|
||||
// 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
|
||||
// content script would start synced while background keeps relaying us as
|
||||
// "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') {
|
||||
// content.js resync: hand back the host's extrapolated current position.
|
||||
sendResponse({ target: getHostSyncTarget() });
|
||||
@@ -1782,6 +1808,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
currentRoom = null;
|
||||
controlMode = CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = null;
|
||||
controllers = [];
|
||||
serverCapabilities = [];
|
||||
hcmDesynced = false;
|
||||
// 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') {
|
||||
const processEvent = () => {
|
||||
// Host Control Mode (sender-side): a guest in host-only mode must 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).
|
||||
// Host Control Mode (sender-side): a non-controller in host-only mode must
|
||||
// not drive the room. Don't broadcast; hand the action back to content.js so
|
||||
// it can snap the local player back / offer desync.
|
||||
// Defensive: require a known hostPeerId (L-6) — otherwise the actual
|
||||
// host would gate themselves if state ever becomes inconsistent.
|
||||
if (controlMode === CONTROL_MODES.HOST_ONLY && hostPeerId && !amHost() &&
|
||||
// owner would gate themselves if state ever becomes inconsistent.
|
||||
if (controlMode === CONTROL_MODES.HOST_ONLY && hostPeerId && !amController() &&
|
||||
HOST_ONLY_GATED_ACTIONS.includes(message.action)) {
|
||||
addLog(`Host-only: blocked local ${message.action} (you are a guest)`, 'warn');
|
||||
if (sender.tab && sender.tab.id) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user