feat(host-control-mode): explicit server capabilities for feature detection

Replace the implicit "hostPeerId present ⇒ feature supported" heuristic with an
explicit capabilities list the relay advertises in ROOM_DATA. Cleaner, self-
documenting, and the extensible hook for the planned co-host feature (owner
promotes guests to extra controllers) — a future 'co-host' capability + events
slot in without a protocol bump.

- shared: CAPABILITIES { HOST_CONTROL }.
- server: SERVER_CAPABILITIES advertised in ROOM_DATA.
- background: track serverCapabilities (empty against older relay), serverSupports(),
  thread hostControlSupported through GET_STATUS / GET_CONTROL_MODE / CONTROL_MODE.
- popup: gate the host-control card on the explicit capability instead of hostPeerId.

Backwards-compatible both ways: old relay omits the field → feature stays hidden
(no errors); old client ignores the field. WS test asserts ROOM_DATA advertises
the capability. Adds docs/host-control-mode-TESTING.md (beta-server setup, wss
caveat, two-new-clients note, verification checklist).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
KoalaDev
2026-06-27 04:48:45 +02:00
parent 9feafab617
commit 9963da2ebc
7 changed files with 143 additions and 14 deletions
+15 -5
View File
@@ -1,4 +1,4 @@
import { EVENTS, CONTROL_MODES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT } from './shared/constants.js';
import { EVENTS, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT } from './shared/constants.js';
import { generateUsername } from './shared/names.js';
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
import { sameEpisode } from './episode-utils.js';
@@ -74,6 +74,10 @@ const lastSeqBySender = {}; // senderId → last received seq (sta
// --- Host Control Mode ---
let controlMode = CONTROL_MODES.EVERYONE; // 'everyone' | 'host-only'
let hostPeerId = null; // peerId of the room host (creator / fallback)
// Features the connected relay advertises in ROOM_DATA. Empty against an older
// relay (no capabilities field) → host-control UI/behavior stays unavailable.
let serverCapabilities = [];
function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); }
// Local peer's desync state (content.js reports it via HCM_DESYNC_STATE). Relayed
// in heartbeats so the host's popup UI can show "Solo" instead of silently
// appearing un-ACK'd.
@@ -153,9 +157,10 @@ function ensureState() {
if (data.history) history = [...history, ...data.history].slice(0, 20);
if (data.currentRoom) {
currentRoom = data.currentRoom;
// Host Control Mode: restore role/mode from persisted room.
// Host Control Mode: restore role/mode/capabilities from persisted room.
controlMode = currentRoom.controlMode || CONTROL_MODES.EVERYONE;
hostPeerId = currentRoom.hostPeerId || null;
serverCapabilities = Array.isArray(currentRoom.capabilities) ? currentRoom.capabilities : [];
}
if (data.hcmDesynced !== undefined) hcmDesynced = data.hcmDesynced;
if (data.lastActionState) lastActionState = data.lastActionState;
@@ -472,6 +477,7 @@ async function leaveRoomAfterIdleGrace(reason) {
currentRoom = null;
controlMode = CONTROL_MODES.EVERYONE;
hostPeerId = null;
serverCapabilities = [];
hcmDesynced = false;
// Notify content.js/popup BEFORE currentTabId is cleared so they can reset
// any stale guest-side HCM state (dialog/badge/desync) — H-2.
@@ -695,7 +701,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() };
const payload = { type: 'CONTROL_MODE', controlMode, hostPeerId, amHost: amHost(), hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL) };
chrome.runtime.sendMessage(payload).catch(() => {});
if (currentTabId) {
const tabId = parseInt(currentTabId);
@@ -955,6 +961,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;
serverCapabilities = Array.isArray(data.capabilities) ? data.capabilities : [];
hcmEnforceDesyncInvariant();
broadcastControlMode();
markRoomPotentiallyIdle();
@@ -1588,6 +1595,7 @@ function leaveOldRoomIfSwitching(newRoomId) {
currentRoom = null;
controlMode = CONTROL_MODES.EVERYONE;
hostPeerId = null;
serverCapabilities = [];
hcmDesynced = false;
// Notify content.js/popup so they drop any guest-side HCM state from the
// previous room (badge/dialog/desync) — H-2/H-3.
@@ -1706,7 +1714,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
ping: currentPingMs,
controlMode,
hostPeerId,
amHost: amHost()
amHost: amHost(),
hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL)
});
} else if (message.type === 'SET_CONTROL_MODE') {
// Popup (host) toggles the room control mode. Server validates host authority
@@ -1727,7 +1736,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
// 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 });
sendResponse({ controlMode, hostPeerId, amHost: amHost(), desynced: hcmDesynced, hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL) });
} else if (message.type === 'REQUEST_HOST_SYNC') {
// content.js resync: hand back the host's extrapolated current position.
sendResponse({ target: getHostSyncTarget() });
@@ -1768,6 +1777,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
currentRoom = null;
controlMode = CONTROL_MODES.EVERYONE;
hostPeerId = null;
serverCapabilities = [];
hcmDesynced = false;
// Notify content.js/popup BEFORE currentTabId is cleared so they drop any
// stale guest-side HCM state (dialog/badge/desync) — H-2/H-3.
+7 -7
View File
@@ -249,7 +249,7 @@ async function init() {
updatePingDisplay(res.ping);
updatePeerList(res.peers);
lastKnownPeers = res.peers || [];
updateHostControlUI(res.controlMode, res.amHost, res.hostPeerId, res.status === 'connected');
updateHostControlUI(res.controlMode, res.amHost, res.hostControlSupported, res.status === 'connected');
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
// If user has a room configured but background is not connected (disconnected or idle),
@@ -313,14 +313,14 @@ function toggleUIState(inRoom) {
// True when we're a guest in a host-only room → remote-control buttons are locked.
let hcmGuestLocked = false;
function updateHostControlUI(controlMode, amHost, hostPeerId, inRoom) {
function updateHostControlUI(controlMode, amHost, hostControlSupported, inRoom) {
const card = elements.hostControlCard;
if (!card) return;
const hostOnly = controlMode === 'host-only';
// The server only sends hostPeerId once it supports host control. Against an
// older relay it's absent → the feature is unavailable, so hide the card
// Explicit capability advertised by the relay in ROOM_DATA. Against an older
// relay it's false/absent → the feature is unavailable, so hide the card
// entirely instead of showing a misleading "Guest".
const serverSupportsHostControl = !!hostPeerId;
const serverSupportsHostControl = !!hostControlSupported;
// Only show the card when it's actually meaningful:
// - host: always (so they can enable/disable host-only)
// - guest: only while host-only is active (explains why they can't control)
@@ -1745,7 +1745,7 @@ chrome.runtime.onMessage.addListener((msg) => {
if (msg.peers) detectPeerChanges(msg.peers);
} else if (msg.type === 'CONTROL_MODE') {
const inRoom = elements.sectionActive && elements.sectionActive.style.display === 'block';
updateHostControlUI(msg.controlMode, msg.amHost, msg.hostPeerId, inRoom);
updateHostControlUI(msg.controlMode, msg.amHost, msg.hostControlSupported, inRoom);
} else if (msg.type === 'CONNECTION_STATUS') {
if (msg.status === 'connected' || msg.status === 'disconnected') {
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
@@ -1763,7 +1763,7 @@ chrome.runtime.onMessage.addListener((msg) => {
if (res.peers) updatePeerList(res.peers);
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
updatePingDisplay(res.ping);
updateHostControlUI(res.controlMode, res.amHost, res.hostPeerId, true);
updateHostControlUI(res.controlMode, res.amHost, res.hostControlSupported, true);
});
}
if (msg.status === 'disconnected') {