fix(host-control-mode): harden gates, close UX races, debounce toggles, surface desync state

Bis auf peerId-Auth (akzeptiert) alle Audit-Findings behoben:
- Snap-back spielt nicht mehr blind ab, wenn Host-Status unbekannt
- Teardown-Pfade broadcasten jetzt CONTROL_MODE → kein verwaistes Badge/Dialog
- Dialog-Timer bei Ersetzung sauber gecleart
- Server re-synchronisiert Sender bei nicht-Host-Reject
- Popup-Toggle revertiert bei Server-Ablehnung
- Desync-Zweig sendet keine Phantom-ACKs mehr an Host
- Toggle-Debouncing serverseitig (500ms pro Raum)
- Button-Text auf Unlock zurückgesetzt
- Live-Erkennung defensiver, Resync-Retry bei fehlendem Target, Badge-Retry

Neu: Host sieht 'Solo'-Badge für desyncte Gäste via PEER_STATUS-Heartbeat
(16 Sprachen, neuer i18n-Key BADGE_DESYNCED/TOOLTIP_PEER_DESYNCED)

Tests: H-5-Reject-Unicast, M-4-Debounce, Desync-Heartbeat-Relay
This commit is contained in:
KoalaDev
2026-06-26 13:07:17 +02:00
parent 69f8f4cfd7
commit 58af258fd3
20 changed files with 245 additions and 20 deletions
+32 -2
View File
@@ -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)
// 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.
let hcmDesynced = false;
function amHost() { return !!peerId && hostPeerId === peerId; }
// Room-moving actions a guest may not initiate while in host-only mode.
const HOST_ONLY_GATED_ACTIONS = [
@@ -281,6 +285,7 @@ function createPeerData(raw) {
currentTime: raw.currentTime != null ? raw.currentTime : null,
volume: raw.volume != null ? raw.volume : null,
muted: raw.muted != null ? raw.muted : null,
desynced: raw.desynced === true, // HCM: peer is watching on their own
lastHeartbeat: Date.now()
};
}
@@ -465,6 +470,10 @@ async function leaveRoomAfterIdleGrace(reason) {
currentRoom = null;
controlMode = CONTROL_MODES.EVERYONE;
hostPeerId = null;
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.
broadcastControlMode();
currentTabId = null;
currentTabTitle = null;
roomIdleSince = null;
@@ -915,7 +924,11 @@ function handleServerEvent(event, data) {
// Host Control Mode (receiver-side backstop): in host-only mode, ignore
// room-moving events from any non-host peer. 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
// 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');
@@ -1199,6 +1212,7 @@ function handleServerEvent(event, data) {
peer.mediaTitle = data.mediaTitle !== undefined ? data.mediaTitle : peer.mediaTitle;
peer.volume = data.volume !== undefined ? data.volume : peer.volume;
peer.muted = data.muted !== undefined ? data.muted : peer.muted;
peer.desynced = data.desynced === true;
const timeSinceReactive = peer.lastReactiveUpdate ? (Date.now() - peer.lastReactiveUpdate) : Infinity;
const ignoreStatus = timeSinceReactive < 300;
@@ -1553,6 +1567,10 @@ function leaveOldRoomIfSwitching(newRoomId) {
currentRoom = null;
controlMode = CONTROL_MODES.EVERYONE;
hostPeerId = null;
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.
broadcastControlMode();
if (storageInitialized) chrome.storage.session.set({ currentRoom: null });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
@@ -1689,6 +1707,12 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} else if (message.type === 'REQUEST_HOST_SYNC') {
// content.js resync: hand back the host's extrapolated current position.
sendResponse({ target: getHostSyncTarget() });
} else if (message.type === 'HCM_DESYNC_STATE') {
// content.js tells us whether the local user chose to watch on their own.
// Mirrored into heartbeats so the host's UI can show "Solo" instead of
// silently waiting for ACKs that will never come.
hcmDesynced = !!message.desynced;
sendResponse({ status: 'ok' });
} else if (message.type === 'LEAVE_ROOM') {
connectIntent = false;
reconnectFailed = false;
@@ -1699,6 +1723,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
currentRoom = null;
controlMode = CONTROL_MODES.EVERYONE;
hostPeerId = null;
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.
broadcastControlMode();
currentTabId = null;
currentTabTitle = null;
roomIdleSince = null;
@@ -1822,7 +1850,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
// 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).
if (controlMode === CONTROL_MODES.HOST_ONLY && !amHost() &&
// 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() &&
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) {
@@ -1974,7 +2004,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
markRoomUseful();
getSettings().then(settings => {
const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle };
const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle, desynced: hcmDesynced };
const otherCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.filter(p => (typeof p === 'object' ? p.peerId : p) !== peerId).length : 0;
if (otherCount > 0) emit(EVENTS.PEER_STATUS, statusPayload);
+88 -15
View File
@@ -109,10 +109,12 @@
// 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 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
let hcmLastUserGestureAt = 0; // for deliberate-vs-involuntary classification
let hcmBufferingUntil = 0; // set on 'waiting' — buffering grace window
let hcmDialogTimer = null; // 8s auto-stay timer — cleared on dialog replace (H-4)
const HCM_USER_GESTURE_MS = 1000;
const HCM_BUFFERING_GRACE_MS = 1500;
const HCM_SNAP_BACK_COOLDOWN_MS = 1000;
@@ -147,6 +149,10 @@
// (Twitch/YouTube-live with rewind) reports a *finite, sliding* duration — its
// seekable window doesn't start at 0, which we use as the DVR signal.
function hcmIsLive(video) {
// Don't trust duration before metadata has loaded (readyState >= 1) —
// otherwise pre-loaded videos report NaN and get misclassified as live,
// which suppresses the desync dialog (L-2).
if (video.readyState < 1) return false;
if (!Number.isFinite(video.duration)) return true;
try {
const s = video.seekable;
@@ -164,10 +170,12 @@
if (target && Number.isFinite(target.targetTime)) {
tryMediaAction(EVENTS.SEEK, { targetTime: target.targetTime });
}
// Adopt the host's play/pause state (default: resume playing).
// Adopt the host's play/pause state — but ONLY if we actually know it.
// Defaulting to PLAY when the state is unknown would auto-resume a paused
// video against the host's real state (H-1).
if (target && target.playbackState === 'paused') {
tryMediaAction(EVENTS.PAUSE);
} else {
} else if (target && target.playbackState === 'playing') {
tryMediaAction(EVENTS.PLAY);
}
reportLog('Host-only: snapped back to host position', 'info');
@@ -181,12 +189,16 @@
// (join race, EC-5) — otherwise we'd miss the dialog/snap-back.
hcmControlMode = 'host-only';
hcmAmHost = false;
if (Date.now() < hcmSnapBackCooldownUntil) return; // EC-4 loop guard
if (hcmDesynced) return; // already solo, nothing to do
const intent = hcmClassifyIntent();
if (intent === 'live') return; // EC-15: leave the guest alone on live
if (intent === 'involuntary') {
// EC-4 loop guard: only the silent auto snap-back is suppressed by the
// cooldown — the deliberate dialog path below must still go through,
// otherwise a second deliberate pause inside the cooldown window leaves
// the user stuck paused with no UI (M-3).
if (Date.now() < hcmSnapBackCooldownUntil) return;
// Buffering/ads/throttle — silently re-sync, no dialog spam.
hcmSnapBackToHost(target);
return;
@@ -201,6 +213,7 @@
// Shadow DOM so the page's CSS can't restyle or hide our controls.
let hcmDialogHost = null; // shadow host element for the dialog
let hcmBadgeHost = null; // shadow host element for the persistent badge
let hcmBadgePending = false; // retry flag for early-injection badge creation (L-4)
function hcmEl(tag, css, text) {
const el = document.createElement(tag);
@@ -210,6 +223,9 @@
}
function hcmRemoveDialog() {
// Cancel any pending auto-stay timer so a replaced dialog's stale closure
// can't later remove its successor / snap to an outdated target (H-4).
if (hcmDialogTimer) { clearTimeout(hcmDialogTimer); hcmDialogTimer = null; }
if (hcmDialogHost) { hcmDialogHost.remove(); hcmDialogHost = null; }
}
@@ -234,33 +250,77 @@
hcmDialogHost = host;
let settled = false;
const stay = () => { if (settled) return; settled = true; hcmRemoveDialog(); hcmSnapBackToHost(target); };
// Re-query the host's current position on click instead of using the
// potentially stale target captured at HOST_BLOCKED time (M-1).
const stay = () => {
if (settled) return; settled = true; hcmRemoveDialog();
chrome.runtime.sendMessage({ type: 'REQUEST_HOST_SYNC' }, (res) => {
if (chrome.runtime.lastError) return;
if (res && res.target) hcmSnapBackToHost(res.target);
});
};
const solo = () => { if (settled) return; settled = true; hcmRemoveDialog(); hcmEnterDesync(); };
stayBtn.addEventListener('click', stay);
soloBtn.addEventListener('click', solo);
// EC-18: if the user ignores the prompt, default to staying in sync.
setTimeout(() => { if (!settled) stay(); }, 8000);
hcmDialogTimer = setTimeout(() => { if (!settled) stay(); }, 8000);
}
function hcmEnterDesync() {
hcmDesynced = true;
reportLog('Host-only: you chose to watch on your own (desynced)', 'warn');
// Notify background so it can relay our desynced state to the host via
// heartbeats — the host's UI then knows we're not following commands
// instead of appearing silently un-ACK'd.
chrome.runtime.sendMessage({ type: 'HCM_DESYNC_STATE', desynced: true }).catch(() => {});
hcmShowBadge();
}
function hcmExitDesync() {
const wasDesynced = hcmDesynced;
hcmDesynced = false;
hcmRemoveBadge();
if (wasDesynced) {
chrome.runtime.sendMessage({ type: 'HCM_DESYNC_STATE', desynced: false }).catch(() => {});
}
// Resync: ask background for the host's current position and snap to it.
chrome.runtime.sendMessage({ type: 'REQUEST_HOST_SYNC' }, (res) => {
if (chrome.runtime.lastError) return;
if (res && res.target) hcmSnapBackToHost(res.target);
});
// Retry briefly if the host's state isn't known yet (e.g. they just paused
// and no heartbeat has propagated) — otherwise the user's "Resync" tap is
// a silent no-op and they think they're synced when they aren't (L-3).
let attempts = 0;
const tryResync = () => {
chrome.runtime.sendMessage({ type: 'REQUEST_HOST_SYNC' }, (res) => {
if (chrome.runtime.lastError || !res || !res.target) {
if (++attempts < 5) setTimeout(tryResync, 250);
else reportLog('Host-only: resync requested but host state unavailable', 'warn');
return;
}
hcmSnapBackToHost(res.target);
});
};
tryResync();
reportLog('Host-only: resynced with the host', 'info');
}
function hcmShowBadge() {
if (hcmBadgeHost || !document.body) return;
if (hcmBadgeHost) return;
if (!document.body) {
// Body not ready yet (very early injection). Defer until DOMReady,
// otherwise the desynced user silently never sees the badge (L-4).
if (!hcmBadgePending) {
hcmBadgePending = true;
const retry = () => {
hcmBadgePending = false;
if (hcmDesynced && !hcmBadgeHost) hcmShowBadge();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', retry, { once: true });
} else {
setTimeout(retry, 50);
}
}
return;
}
const host = hcmEl('div', 'all:initial');
const root = host.attachShadow({ mode: 'open' });
const b = hcmEl('div', 'position:fixed;z-index:2147483646;right:16px;bottom:16px;background:#b45309;color:#fff;font:13px/1.3 system-ui,sans-serif;padding:8px 12px;border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.4);cursor:pointer;display:flex;align-items:center;gap:8px');
@@ -276,9 +336,16 @@
}
function hcmReset() {
const wasDesynced = hcmDesynced;
hcmDesynced = false;
hcmRemoveDialog();
hcmRemoveBadge();
// If we were desynced, notify background so it stops reporting us as
// desynced in heartbeats (otherwise the host's UI keeps showing the
// stale Solo badge until the next state change).
if (wasDesynced) {
chrome.runtime.sendMessage({ type: 'HCM_DESYNC_STATE', desynced: false }).catch(() => {});
}
}
function reportLog(message, level = 'info') {
@@ -724,10 +791,15 @@
// Host Control Mode: room mode/role changed.
if (message.type === 'CONTROL_MODE') {
const wasGated = hcmIsGuestGated();
const prevHostPeerId = hcmHostPeerId;
hcmControlMode = message.controlMode || 'everyone';
hcmAmHost = !!message.amHost;
// Leaving host-only, or becoming host, clears any guest-side state.
if (wasGated && !hcmIsGuestGated()) hcmReset();
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
// teardown broadcast) — clears stale desync so a rejoin starts clean (H-3).
const hostChanged = prevHostPeerId !== null && hcmHostPeerId !== prevHostPeerId;
if ((wasGated && !hcmIsGuestGated()) || hostChanged) hcmReset();
sendResponse({ ok: true });
return true;
}
@@ -752,14 +824,15 @@
let actionCompleted = false;
// Host Control Mode: while watching on our own (desynced), don't apply
// host commands. Still ACK so the host's force-sync doesn't stall on us.
// host commands. Only ACK FORCE_SYNC_PREPARE — that's the one the host's
// force-sync flow actually waits on. Skipping CMD_ACKs for PLAY/PAUSE/SEEK
// is intentional so the host's UI honestly reflects that we didn't apply
// the command (M-2); sending them would make the host think we're synced.
if (hcmDesynced) {
const soloIgnored = [EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE];
if (soloIgnored.includes(action)) {
if (action === EVENTS.FORCE_SYNC_PREPARE) {
chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' }).catch(() => {});
} else if (action !== EVENTS.FORCE_SYNC_EXECUTE) {
chrome.runtime.sendMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId }).catch(() => {});
}
return;
}
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "Der Host steuert die Wiedergabe für alle.",
"BADGE_HOST": "Host",
"BADGE_GUEST": "Gast",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Schaut alleine — ignoriert die Befehle des Hosts",
"NO_PEERS_CONNECTED": "Keine Teilnehmer verbunden",
"BTN_LEAVE_ROOM": "Raum verlassen",
"LABEL_SELECT_VIDEO": "Video auswählen",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "The host controls playback for everyone.",
"BADGE_HOST": "Host",
"BADGE_GUEST": "Guest",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Watching on their own — host's commands are ignored",
"NO_PEERS_CONNECTED": "No peers connected",
"BTN_LEAVE_ROOM": "Leave Room",
"LABEL_SELECT_VIDEO": "Select Video",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "El anfitrión controla la reproducción para todos.",
"BADGE_HOST": "Anfitrión",
"BADGE_GUEST": "Invitado",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Viendo por su cuenta — ignora los comandos del anfitrión",
"NO_PEERS_CONNECTED": "Sin participantes conectados",
"BTN_LEAVE_ROOM": "Salir de la sala",
"LABEL_SELECT_VIDEO": "Seleccionar video",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "L'hôte contrôle la lecture pour tout le monde.",
"BADGE_HOST": "Hôte",
"BADGE_GUEST": "Invité",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Regarde de son côté — ignore les commandes de l'hôte",
"NO_PEERS_CONNECTED": "Aucun membre connecté",
"BTN_LEAVE_ROOM": "Quitter le salon",
"LABEL_SELECT_VIDEO": "Choisir une vidéo",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "L'host controlla la riproduzione per tutti.",
"BADGE_HOST": "Host",
"BADGE_GUEST": "Ospite",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Guarda per conto proprio — ignora i comandi dell'host",
"NO_PEERS_CONNECTED": "Nessun partecipante connesso",
"BTN_LEAVE_ROOM": "Lascia Stanza",
"LABEL_SELECT_VIDEO": "Seleziona Video",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "ホストが全員の再生を操作します。",
"BADGE_HOST": "ホスト",
"BADGE_GUEST": "ゲスト",
"BADGE_DESYNCED": "ソロ",
"TOOLTIP_PEER_DESYNCED": "単独で視聴中 — ホストのコマンドを無視しています",
"NO_PEERS_CONNECTED": "接続しているメンバーはいません",
"BTN_LEAVE_ROOM": "ルームを退室",
"LABEL_SELECT_VIDEO": "ビデオを選択",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "호스트가 모두의 재생을 제어합니다.",
"BADGE_HOST": "호스트",
"BADGE_GUEST": "게스트",
"BADGE_DESYNCED": "단독",
"TOOLTIP_PEER_DESYNCED": "혼자 시청 중 — 호스트 명령을 무시합니다",
"NO_PEERS_CONNECTED": "연결된 참여자가 없습니다",
"BTN_LEAVE_ROOM": "방 나가기",
"LABEL_SELECT_VIDEO": "비디오 선택",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "De host bedient de weergave voor iedereen.",
"BADGE_HOST": "Host",
"BADGE_GUEST": "Gast",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Zelfstandig aan het kijken — negeert de commando's van de host",
"NO_PEERS_CONNECTED": "Geen deelnemers verbonden",
"BTN_LEAVE_ROOM": "Kamer verlaten",
"LABEL_SELECT_VIDEO": "Selecteer video",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "Host steruje odtwarzaniem dla wszystkich.",
"BADGE_HOST": "Host",
"BADGE_GUEST": "Gość",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Ogląda samodzielnie — ignoruje polecenia hosta",
"NO_PEERS_CONNECTED": "Brak połączonych uczestników",
"BTN_LEAVE_ROOM": "Opuść pokój",
"LABEL_SELECT_VIDEO": "Wybierz wideo",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "O anfitrião controla a reprodução para todos.",
"BADGE_HOST": "Anfitrião",
"BADGE_GUEST": "Convidado",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Assistindo por conta própria — ignora os comandos do anfitrião",
"NO_PEERS_CONNECTED": "Sem participantes conectados",
"BTN_LEAVE_ROOM": "Sair da sala",
"LABEL_SELECT_VIDEO": "Selecionar vídeo",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "O anfitrião controla a reprodução para todos.",
"BADGE_HOST": "Anfitrião",
"BADGE_GUEST": "Convidado",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "A ver por conta própria — ignora os comandos do anfitrião",
"NO_PEERS_CONNECTED": "Nenhum participante ligado",
"BTN_LEAVE_ROOM": "Sair da Sala",
"LABEL_SELECT_VIDEO": "Selecionar Vídeo",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "Ведущий управляет воспроизведением для всех.",
"BADGE_HOST": "Ведущий",
"BADGE_GUEST": "Гость",
"BADGE_DESYNCED": "Соло",
"TOOLTIP_PEER_DESYNCED": "Смотрит отдельно — игнорирует команды ведущего",
"NO_PEERS_CONNECTED": "Нет подключенных участников",
"BTN_LEAVE_ROOM": "Выйти из комнаты",
"LABEL_SELECT_VIDEO": "Выбрать видео",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "Oynatmayı herkes için sunucu kontrol eder.",
"BADGE_HOST": "Sunucu",
"BADGE_GUEST": "Misafir",
"BADGE_DESYNCED": "Solo",
"TOOLTIP_PEER_DESYNCED": "Tek başına izliyor — sunucu komutlarını yok sayar",
"NO_PEERS_CONNECTED": "Bağlı kimse yok",
"BTN_LEAVE_ROOM": "Odadan Ayrıl",
"LABEL_SELECT_VIDEO": "Video Seç",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "Ведучий керує відтворенням для всіх.",
"BADGE_HOST": "Ведучий",
"BADGE_GUEST": "Гість",
"BADGE_DESYNCED": "Соло",
"TOOLTIP_PEER_DESYNCED": "Дивиться окремо — ігнорує команди ведучого",
"NO_PEERS_CONNECTED": "Немає підключених однорангових пристроїв",
"BTN_LEAVE_ROOM": "Вийти з кімнати",
"LABEL_SELECT_VIDEO": "Виберіть Відео",
+2
View File
@@ -50,6 +50,8 @@
"NOTICE_HOST_CONTROLS": "由主持人为所有人控制播放。",
"BADGE_HOST": "主持人",
"BADGE_GUEST": "访客",
"BADGE_DESYNCED": "单独",
"TOOLTIP_PEER_DESYNCED": "单独观看中 — 忽略主持人的命令",
"NO_PEERS_CONNECTED": "没有对等点连接",
"BTN_LEAVE_ROOM": "离开房间",
"LABEL_SELECT_VIDEO": "选择视频",
+19
View File
@@ -346,6 +346,15 @@ function setRemoteControlsLocked(locked) {
btn.style.cursor = locked ? 'not-allowed' : '';
btn.title = locked ? (getMessage('NOTICE_HOST_CONTROLS') || 'The host controls playback for everyone.') : '';
});
// When unlocking, also restore the default labels. The action handlers leave
// the text in a transitional state ("Playing..." / "Pausing...") and the 2.5s
// safety reset skips the refresh while we were guest-locked, so without this
// the button can be re-enabled with stale text after the host disables
// host-only (L-1).
if (!locked) {
if (elements.playBtn) elements.playBtn.textContent = getMessage('BTN_PLAY') || 'Play';
if (elements.pauseBtn) elements.pauseBtn.textContent = getMessage('BTN_PAUSE') || 'Pause';
}
}
if (elements.hostControlToggle) {
@@ -650,6 +659,16 @@ function updatePeerList(peers) {
header.appendChild(you);
}
// Host Control Mode: show "Solo" badge for peers watching on their own.
if (typeof p === 'object' && p.desynced) {
const solo = document.createElement('span');
solo.style.cssText = 'font-size:10px; color:#fff; background:#b45309; padding:2px 6px; border-radius:6px; font-weight:600;';
const soloText = getMessage('BADGE_DESYNCED') || 'Solo';
solo.textContent = soloText;
solo.title = getMessage('TOOLTIP_PEER_DESYNCED') || soloText;
header.appendChild(solo);
}
peerItem.appendChild(header);
// Media Info
+51 -1
View File
@@ -3,6 +3,7 @@ import http from 'node:http';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { connectionCounts, clearRateLimitMaps } from '../server/rate-limiter.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(path.join(__dirname, '..', 'server', 'package.json'));
@@ -23,6 +24,9 @@ function a(ws) { if (ws._m.length) { const r=ws._m.shift(); return r.startsWith(
async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-st<ms) { for(let i=0;i<ws._m.length;i++){const r=ws._m[i];ws._m.splice(i,1);if(r.startsWith('42')){try{const[e]=JSON.parse(r.substring(2));if(e===evt)return e}catch{/* skip */}}} await new Promise(r=>setTimeout(r,50));} throw Error(`wait:${evt}`); }
async function j(ws, rid, pid, pw=null) { s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0'}); assert.equal((await a(ws))[0],'room_data'); }
function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; }
// Test suite opens >10 connections/min — clear the IP connection counter so the
// connection rate limiter doesn't mask test failures (test-only, never at runtime).
function resetConnectionRate() { connectionCounts.clear(); clearRateLimitMaps(); }
try {
process.env.ADMIN_METRICS_TOKEN = 'ws-integration-test-32chars-minimum!';
@@ -57,6 +61,7 @@ try {
s(p1,'leave_room',{}); const [ev,d]=await a(p2); assert.equal(ev,'peer_status');assert.equal(d.status,'left');
close();
resetConnectionRate();
// --- Host Control Mode ---
const hrid = 'host-'+Date.now();
@@ -77,10 +82,31 @@ try {
s(h1,'pause',{currentTime:7}); await w(h2,'pause');
h1._m.length = h2._m.length = 0;
// Guest cannot change the control mode -> no broadcast
// desynced flag is relayed through PEER_STATUS heartbeats so the host's UI
// can show "Solo" for guests watching on their own.
s(h2,'peer_status',{status:'heartbeat',desynced:true,currentTime:42,playbackState:'playing'});
let hbData = null; const hbStart = Date.now();
while (Date.now()-hbStart < 600 && !hbData) {
for (let i=0;i<h1._m.length;i++){ const r=h1._m[i]; if(r.startsWith('42')){ const [e,dd]=JSON.parse(r.substring(2)); if(e==='peer_status'){ h1._m.splice(i,1); hbData=dd; break; } } }
await new Promise(r=>setTimeout(r,30));
}
assert.ok(hbData && hbData.desynced === true, 'desynced=true relayed in heartbeat');
h1._m.length = h2._m.length = 0;
// Guest cannot change the control mode -> host must NOT receive a broadcast.
// The rejected sender gets a unicast of the *actual* state so any optimistic
// UI reverts (H-5); assert both halves.
s(h2,'set_control_mode',{controlMode:'everyone'});
let guestSetBlocked = false; try { await w(h1,'control_mode',600); } catch { guestSetBlocked = true; }
assert.ok(guestSetBlocked, 'non-host cannot set control mode');
let rejectSync = null; const rsStart = Date.now();
while (Date.now()-rsStart < 600 && !rejectSync) {
for (let i=0;i<h2._m.length;i++){ const r=h2._m[i]; if(r.startsWith('42')){ const [e,dd]=JSON.parse(r.substring(2)); if(e==='control_mode'){ h2._m.splice(i,1); rejectSync=dd; break; } } }
await new Promise(r=>setTimeout(r,30));
}
assert.ok(rejectSync && rejectSync.controlMode==='host-only' && rejectSync.hostPeerId==='host1',
'rejected sender is re-synced to actual state');
h1._m.length = h2._m.length = 0;
// Host leaves -> room falls back to 'everyone' and reassigns host to the guest
s(h1,'leave_room',{});
@@ -92,6 +118,30 @@ try {
assert.ok(fb && fb.controlMode==='everyone' && fb.hostPeerId==='guest1', 'host leave -> fallback everyone + new host');
close();
// --- M-4: rapid control-mode toggles are debounced per-room ---
const drid = 'debounce-'+Date.now();
const db1 = await c(), db2 = await c();
await j(db1, drid, 'dhost'); await j(db2, drid, 'dguest'); db1._m.length = db2._m.length = 0;
// First toggle (everyone → host-only) goes through.
s(db1,'set_control_mode',{controlMode:'host-only'});
await w(db1,'control_mode'); await w(db2,'control_mode');
db1._m.length = db2._m.length = 0;
// Immediate second toggle (host-only → everyone) should be debounced:
// broadcast goes to neither peer, but sender gets a re-sync unicast.
s(db1,'set_control_mode',{controlMode:'everyone'});
let dGuestGotIt = false; try { await w(db2,'control_mode',600); } catch { dGuestGotIt = true; }
assert.ok(dGuestGotIt, 'rapid control-mode toggle is debounced (no broadcast)');
let dSenderResync = null; const dsStart = Date.now();
while (Date.now()-dsStart < 600 && !dSenderResync) {
for (let i=0;i<db1._m.length;i++){ const r=db1._m[i]; if(r.startsWith('42')){ const [e,dd]=JSON.parse(r.substring(2)); if(e==='control_mode'){ db1._m.splice(i,1); dSenderResync=dd; break; } } }
await new Promise(r=>setTimeout(r,30));
}
assert.ok(dSenderResync && dSenderResync.controlMode==='host-only',
'debounced toggle re-syncs sender to actual state');
close();
// --- Password room ---
const prid = 'pw-'+Date.now();
const pw1 = await c(); await j(pw1, prid, 'admin', 's3cret');
+25 -2
View File
@@ -161,6 +161,11 @@ const HOST_ONLY_GATED_EVENTS = new Set([
EVENTS.EPISODE_LOBBY_CANCEL
]);
// M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly
// toggling host from thrashing every guest's UI (locked/unlocked/locked...) and
// from generating one broadcast per toggle across all peers.
const CONTROL_MODE_MIN_INTERVAL_MS = 500;
function log(type, message, details = '') {
const debugLogging = process.env.DEBUG_LOGGING === '1';
const isVerbose = type === 'CONN' || type === 'ROOM' || type === 'DEDUPE' || type === 'CORS' || type === 'ACKDROP';
@@ -361,7 +366,8 @@ io.on('connection', (socket) => {
lastActivity: Date.now(),
// Host Control Mode: creator (first joiner) is the host.
hostPeerId: peerId,
controlMode: CONTROL_MODES.EVERYONE
controlMode: CONTROL_MODES.EVERYONE,
lastControlModeChangeAt: 0 // M-4: per-room debounce for control-mode toggles
};
rooms.set(roomId, room);
createdByMe = true;
@@ -516,6 +522,7 @@ io.on('connection', (socket) => {
currentTime: data.currentTime !== undefined ? (clampNum(data.currentTime, 0, 86400) ?? existing.currentTime) : existing.currentTime,
volume: data.volume !== undefined ? (clampNum(data.volume, 0, 1) ?? existing.volume) : existing.volume,
muted: data.muted !== undefined ? (validBool(data.muted) ?? existing.muted) : existing.muted,
desynced: data.desynced !== undefined ? (validBool(data.desynced) === true) : (existing.desynced || false),
lastSeen: Date.now()
});
@@ -531,6 +538,7 @@ io.on('connection', (socket) => {
mediaTitle: clamp(data.mediaTitle, 100),
volume: clampNum(data.volume, 0, 1),
muted: validBool(data.muted),
desynced: validBool(data.desynced),
peerId: mapping.peerId,
status: typeof data.status === 'string' ? data.status.substring(0, 16) : undefined,
expectedTitle: clamp(data.expectedTitle, 100),
@@ -612,12 +620,27 @@ io.on('connection', (socket) => {
// Only the host may change the control mode.
if (mapping.peerId !== room.hostPeerId) {
log('AUTH', `Non-host ${mapping.peerId} tried to set control mode in ${mapping.roomId.substring(0, 3)}***`);
// Re-sync the sender to the actual room state so any optimistic UI
// (e.g. popup toggle) reverts — covers stale-local-amHost races (H-5).
socket.emit(EVENTS.CONTROL_MODE, { controlMode: room.controlMode, hostPeerId: room.hostPeerId });
return;
}
if (room.controlMode === mode) return; // no-op, ignore (UI debounce backstop)
// M-4: per-room debounce — reject toggles faster than CONTROL_MODE_MIN_INTERVAL_MS.
// The host still gets the final state via the next legit change; this just
// kills the spam vector.
const now = Date.now();
if (now - room.lastControlModeChangeAt < CONTROL_MODE_MIN_INTERVAL_MS) {
log('ROOM', `Control mode toggle debounced in ${mapping.roomId.substring(0, 3)}***`);
// Re-sync the sender so any optimistic UI matches the actual (unchanged) state.
socket.emit(EVENTS.CONTROL_MODE, { controlMode: room.controlMode, hostPeerId: room.hostPeerId });
return;
}
room.controlMode = mode;
room.lastActivity = Date.now();
room.lastControlModeChangeAt = now;
room.lastActivity = now;
io.to(mapping.roomId).emit(EVENTS.CONTROL_MODE, { controlMode: mode, hostPeerId: room.hostPeerId });
log('ROOM', `Control mode set to '${mode}' by host in room ${mapping.roomId.substring(0, 3)}***`);
});