mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-29 05:19:31 +00:00
feat(extension): host control mode wiring in background (step 3)
Background service worker now understands room control mode (no UI yet): - Track controlMode + hostPeerId + amHost(); adopt from ROOM_DATA, update on CONTROL_MODE, restore from session storage, reset on leave/room-switch. - Receiver-side gate: ignore room-moving events from non-host peers in host-only mode (backstop for old/buggy/modified clients past the server gate). - Sender-side gate: a guest's play/pause/seek/force-sync is not broadcast; instead a HOST_BLOCKED message is sent to the content tab with an extrapolated host sync target (hook for step 4's snap-back + desync dialog). - broadcastControlMode() notifies popup + content tab; GET_STATUS exposes controlMode/hostPeerId/amHost; new SET_CONTROL_MODE message lets the host toggle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+107
-3
@@ -1,4 +1,4 @@
|
||||
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT } from './shared/constants.js';
|
||||
import { EVENTS, CONTROL_MODES, 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';
|
||||
@@ -70,6 +70,29 @@ let isNamespaceJoined = false;
|
||||
let lastActionState = { action: null, senderId: null, timestamp: 0, acks: [] };
|
||||
let localSeq = 0; // Monotonically increasing command sequence for this peer
|
||||
const lastSeqBySender = {}; // senderId → last received seq (stale command guard)
|
||||
|
||||
// --- Host Control Mode ---
|
||||
let controlMode = CONTROL_MODES.EVERYONE; // 'everyone' | 'host-only'
|
||||
let hostPeerId = null; // peerId of the room host (creator / fallback)
|
||||
function amHost() { return !!peerId && hostPeerId === peerId; }
|
||||
// Room-moving actions a guest may not initiate while in host-only mode.
|
||||
const HOST_ONLY_GATED_ACTIONS = [
|
||||
EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK,
|
||||
EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE,
|
||||
EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_LOBBY_CANCEL
|
||||
];
|
||||
// Best-effort estimate of where the room (host) is right now, for guest snap-back.
|
||||
// Extrapolates from the host peer's last known state (±~1s). Used by content.js.
|
||||
function getHostSyncTarget() {
|
||||
if (!currentRoom || !Array.isArray(currentRoom.peers)) return null;
|
||||
const host = currentRoom.peers.find(p => (typeof p === 'object' ? p.peerId : p) === hostPeerId);
|
||||
if (!host || typeof host !== 'object') return null;
|
||||
let targetTime = typeof host.currentTime === 'number' ? host.currentTime : null;
|
||||
if (targetTime !== null && host.playbackState === 'playing' && host.lastHeartbeat) {
|
||||
targetTime += Math.max(0, (Date.now() - host.lastHeartbeat) / 1000);
|
||||
}
|
||||
return { playbackState: host.playbackState || null, targetTime };
|
||||
}
|
||||
const activePorts = new Set(); // New: track active content ports for keep-alive
|
||||
let expectedAcksCount = 0; // Snapshot of peerCount when initiating Force Sync
|
||||
|
||||
@@ -123,7 +146,12 @@ function ensureState() {
|
||||
// New entries (added during boot) must stay at the top (index 0)
|
||||
if (data.logs) logs = [...logs, ...data.logs].slice(0, 200);
|
||||
if (data.history) history = [...history, ...data.history].slice(0, 20);
|
||||
if (data.currentRoom) currentRoom = data.currentRoom;
|
||||
if (data.currentRoom) {
|
||||
currentRoom = data.currentRoom;
|
||||
// Host Control Mode: restore role/mode from persisted room.
|
||||
controlMode = currentRoom.controlMode || CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = currentRoom.hostPeerId || null;
|
||||
}
|
||||
if (data.lastActionState) lastActionState = data.lastActionState;
|
||||
|
||||
if (data.eventQueue) eventQueue = [...eventQueue, ...data.eventQueue].slice(0, 50);
|
||||
@@ -435,6 +463,8 @@ async function leaveRoomAfterIdleGrace(reason) {
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
forceDisconnect();
|
||||
currentRoom = null;
|
||||
controlMode = CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = null;
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
roomIdleSince = null;
|
||||
@@ -638,6 +668,17 @@ async function connect() {
|
||||
}
|
||||
|
||||
|
||||
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() };
|
||||
chrome.runtime.sendMessage(payload).catch(() => {});
|
||||
if (currentTabId) {
|
||||
const tabId = parseInt(currentTabId);
|
||||
if (!isNaN(tabId)) chrome.tabs.sendMessage(tabId, payload).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastConnectionStatus(status) {
|
||||
// No room and no intent to connect → this isn't a failure, it's the normal
|
||||
// resting state. Surface a distinct 'idle' status so the UI can say
|
||||
@@ -871,9 +912,22 @@ function handleServerEvent(event, data) {
|
||||
addLog(`Ignored server event ${event} due to empty payload`, 'warn');
|
||||
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,
|
||||
// so this covers old/buggy/modified clients that slipped through.
|
||||
if (controlMode === CONTROL_MODES.HOST_ONLY &&
|
||||
HOST_ONLY_GATED_ACTIONS.includes(event) &&
|
||||
data.senderId && data.senderId !== hostPeerId) {
|
||||
addLog(`Ignored ${event} from non-host ${data.senderId} (host-only)`, 'warn');
|
||||
return;
|
||||
}
|
||||
switch (event) {
|
||||
case EVENTS.ROOM_DATA:
|
||||
currentRoom = data;
|
||||
// Host Control Mode: adopt room role/mode on (re)join.
|
||||
controlMode = data.controlMode || CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = data.hostPeerId || null;
|
||||
broadcastControlMode();
|
||||
markRoomPotentiallyIdle();
|
||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||
currentRoom.peers = currentRoom.peers.map(p => typeof p === 'object' ? createPeerData(p) : { peerId: p, username: null, tabTitle: null, mediaTitle: null, playbackState: null, currentTime: null, volume: null, muted: null, lastHeartbeat: Date.now() });
|
||||
@@ -931,6 +985,18 @@ function handleServerEvent(event, data) {
|
||||
});
|
||||
});
|
||||
break;
|
||||
case EVENTS.CONTROL_MODE:
|
||||
// Host Control Mode changed (toggle or host-leave fallback).
|
||||
controlMode = data.controlMode || CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = data.hostPeerId || null;
|
||||
if (currentRoom) {
|
||||
currentRoom.controlMode = controlMode;
|
||||
currentRoom.hostPeerId = hostPeerId;
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom });
|
||||
}
|
||||
addLog(`Control mode: ${controlMode}${amHost() ? ' (you are host)' : ''}`, 'info');
|
||||
broadcastControlMode();
|
||||
break;
|
||||
case EVENTS.ROOM_LIST:
|
||||
chrome.runtime.sendMessage({ type: 'ROOM_LIST', rooms: data.rooms }).catch(() => {});
|
||||
break;
|
||||
@@ -1485,6 +1551,8 @@ function leaveOldRoomIfSwitching(newRoomId) {
|
||||
addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
|
||||
forceDisconnect();
|
||||
currentRoom = null;
|
||||
controlMode = CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = null;
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom: null });
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
|
||||
@@ -1596,8 +1664,25 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
version: chrome.runtime.getManifest().version,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
roomPassword: currentRoom ? currentRoom.password : null,
|
||||
ping: currentPingMs
|
||||
ping: currentPingMs,
|
||||
controlMode,
|
||||
hostPeerId,
|
||||
amHost: amHost()
|
||||
});
|
||||
} else if (message.type === 'SET_CONTROL_MODE') {
|
||||
// Popup (host) toggles the room control mode. Server validates host authority
|
||||
// and broadcasts CONTROL_MODE back, which updates our local state + UI.
|
||||
const mode = message.controlMode;
|
||||
if (mode !== CONTROL_MODES.EVERYONE && mode !== CONTROL_MODES.HOST_ONLY) {
|
||||
sendResponse({ status: 'invalid' });
|
||||
return;
|
||||
}
|
||||
if (!amHost()) {
|
||||
sendResponse({ status: 'not_host' });
|
||||
return;
|
||||
}
|
||||
emit(EVENTS.SET_CONTROL_MODE, { controlMode: mode });
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'LEAVE_ROOM') {
|
||||
connectIntent = false;
|
||||
reconnectFailed = false;
|
||||
@@ -1606,6 +1691,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
resetAudioProcessingInTab(currentTabId);
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
currentRoom = null;
|
||||
controlMode = CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = null;
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
roomIdleSince = null;
|
||||
@@ -1726,6 +1813,23 @@ 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).
|
||||
if (controlMode === CONTROL_MODES.HOST_ONLY && !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) {
|
||||
chrome.tabs.sendMessage(sender.tab.id, {
|
||||
type: 'HOST_BLOCKED',
|
||||
action: message.action,
|
||||
target: getHostSyncTarget()
|
||||
}).catch(() => {});
|
||||
}
|
||||
sendResponse({ status: 'blocked_host_only' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Live solo check — recomputed from the current peer list on every
|
||||
// event (the list is updated synchronously on PEER_STATUS join/leave),
|
||||
// never cached, so the instant a peer joins we resume sending.
|
||||
|
||||
Reference in New Issue
Block a user