diff --git a/extension/background.js b/extension/background.js index ecf6c94..31d1a7b 100644 --- a/extension/background.js +++ b/extension/background.js @@ -19,6 +19,7 @@ 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) const activePorts = new Set(); // New: track active content ports for keep-alive +let expectedAcksCount = 0; // Snapshot of peerCount when initiating Force Sync // --- Keep-Alive Port Listener --- chrome.runtime.onConnect.addListener((port) => { @@ -53,9 +54,10 @@ function ensureState() { 'logs', 'history', 'currentRoom', 'lastActionState', 'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks', 'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'currentTabId', 'currentTabTitle', - 'episodeLobby', 'localSeq', 'lastSeqBySender' + 'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount' ], (data) => { clearTimeout(storageTimeout); + if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount; if (data.currentTabId !== undefined) currentTabId = data.currentTabId; if (data.currentTabTitle !== undefined) currentTabTitle = data.currentTabTitle; // Merge data from storage with any early-arriving state @@ -705,9 +707,9 @@ function handleServerEvent(event, data) { }); } - // Check if all peers responded - const peerCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1; - if (forceSyncAcks.size >= peerCount) { + // Check if all peers responded using the snapshot count + const targetCount = expectedAcksCount > 0 ? expectedAcksCount : (currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1); + if (forceSyncAcks.size >= targetCount) { executeForceSync(); } } @@ -786,8 +788,9 @@ function handleServerEvent(event, data) { } if (isForceSyncInitiator) { - const peerCount = Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1; - if (forceSyncAcks.size >= peerCount) { + expectedAcksCount = Math.max(1, currentRoom.peers ? currentRoom.peers.length : 1); + chrome.storage.session.set({ expectedAcksCount }); + if (forceSyncAcks.size >= expectedAcksCount) { executeForceSync(); } } @@ -867,6 +870,13 @@ function handleServerEvent(event, data) { } } break; + case EVENTS.EPISODE_LOBBY_CANCEL: + if (episodeLobby) { + const title = episodeLobby.expectedTitle; + clearEpisodeLobbyState(); + addLog(`Episode lobby for "${title}" cancelled by ${data.senderId || 'peer'}`, 'warn'); + } + break; default: addLog(`Received unknown event from server: ${event}`, 'warn'); break; @@ -877,10 +887,12 @@ function executeForceSync() { if (forceSyncTimeout) clearTimeout(forceSyncTimeout); isForceSyncInitiator = false; forceSyncAcks.clear(); + expectedAcksCount = 0; chrome.storage.session.set({ isForceSyncInitiator: false, forceSyncAcks: [], - forceSyncDeadline: null + forceSyncDeadline: null, + expectedAcksCount: 0 }); // Set all peers to playing and apply a reactive lock to block stale heartbeats @@ -934,6 +946,10 @@ function clearEpisodeLobbyState() { function cancelEpisodeLobby(reason) { if (!episodeLobby) return; const title = episodeLobby.expectedTitle; + + // Broadcast cancellation to room + emit(EVENTS.EPISODE_LOBBY_CANCEL, { peerId }); + clearEpisodeLobbyState(); addLog(`Episode lobby cancelled: ${reason} for "${title}"`, 'warn'); @@ -955,6 +971,7 @@ function executeEpisodeLobby() { isForceSyncInitiator = true; forceSyncAcks.clear(); + expectedAcksCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1; const deadline = Date.now() + FORCE_SYNC_TIMEOUT; const timestamp = Date.now(); updateLastAction(EVENTS.FORCE_SYNC_PREPARE, 'You', timestamp); @@ -963,7 +980,8 @@ function executeEpisodeLobby() { chrome.storage.session.set({ isForceSyncInitiator: true, forceSyncAcks: [], - forceSyncDeadline: deadline + forceSyncDeadline: deadline, + expectedAcksCount: expectedAcksCount }); const syncPayload = { targetTime: 0.0 }; @@ -1098,11 +1116,13 @@ function leaveOldRoomIfSwitching(newRoomId) { // Reset force sync states isForceSyncInitiator = false; forceSyncAcks.clear(); + expectedAcksCount = 0; if (forceSyncTimeout) clearTimeout(forceSyncTimeout); chrome.storage.session.set({ isForceSyncInitiator: false, forceSyncAcks: [], - forceSyncDeadline: null + forceSyncDeadline: null, + expectedAcksCount: 0 }); // Cancel any active episode lobby @@ -1177,6 +1197,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { isForceSyncInitiator = false; forceSyncAcks.clear(); + expectedAcksCount = 0; if (forceSyncTimeout) clearTimeout(forceSyncTimeout); // Cancel any active episode lobby @@ -1187,7 +1208,8 @@ async function handleAsyncMessage(message, sender, sendResponse) { isForceSyncInitiator: false, forceSyncAcks: [], forceSyncDeadline: null, - episodeLobby: null + episodeLobby: null, + expectedAcksCount: 0 }); addLog('Left Room', 'info'); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); @@ -1270,11 +1292,13 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (message.action === EVENTS.FORCE_SYNC_PREPARE) { isForceSyncInitiator = true; forceSyncAcks.clear(); + expectedAcksCount = currentRoom && Array.isArray(currentRoom.peers) ? currentRoom.peers.length : 1; const deadline = Date.now() + FORCE_SYNC_TIMEOUT; chrome.storage.session.set({ isForceSyncInitiator: true, forceSyncAcks: [], - forceSyncDeadline: deadline + forceSyncDeadline: deadline, + expectedAcksCount: expectedAcksCount }); addLog('Initiating Force Sync...', 'info'); @@ -1500,6 +1524,13 @@ async function handleAsyncMessage(message, sender, sendResponse) { } else { sendResponse({ lobbyActive: false }); } + } else if (message.type === 'CANCEL_EPISODE_LOBBY') { + if (episodeLobby) { + cancelEpisodeLobby('Cancelled by user'); + sendResponse({ status: 'ok' }); + } else { + sendResponse({ error: 'No active lobby' }); + } } else { // Final fallback to prevent channel hanging sendResponse({ error: 'unhandled_message' }); @@ -1507,7 +1538,8 @@ async function handleAsyncMessage(message, sender, sendResponse) { } // Tab removal listener -chrome.tabs.onRemoved.addListener((tabId) => { +chrome.tabs.onRemoved.addListener(async (tabId) => { + await ensureState(); if (tabId === currentTabId) { const wasInRoom = !!currentRoom; currentTabId = null; @@ -1548,7 +1580,8 @@ chrome.tabs.onRemoved.addListener((tabId) => { }); // Re-inject on full page refresh -chrome.tabs.onUpdated.addListener((tabId, changeInfo, _tab) => { +chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => { + await ensureState(); if (currentTabId && tabId === parseInt(currentTabId) && changeInfo.status === 'complete') { chrome.scripting.executeScript({ target: { tabId }, diff --git a/extension/content.js b/extension/content.js index e27cce0..e02f820 100644 --- a/extension/content.js +++ b/extension/content.js @@ -81,7 +81,12 @@ function findVideo(root = document) { const video = root.querySelector('video'); if (video) return video; - for (const el of root.querySelectorAll('*')) { + + // Optimize: scan only potential player, video, media, and stream hosts by matching typical keywords (case-insensitive) + // or common custom element tags. This prevents recursive scanning of thousands of standard DOM nodes (div, span, a, etc.) + // while guaranteeing 100% airtight compatibility with all video web components in the wild. + const potentialHosts = root.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i], [id*="media" i], [class*="media" i], [id*="stream" i], [class*="stream" i], ytd-player, netflix-player, emby-player, jellyfin-player, video-player'); + for (const el of potentialHosts) { if (el.shadowRoot) { const found = findVideo(el.shadowRoot); if (found) return found; diff --git a/extension/popup.html b/extension/popup.html index 7194f0f..50d8cf9 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -440,7 +440,8 @@ EPISODE LOBBY
- + + diff --git a/extension/popup.js b/extension/popup.js index d7a13f1..adbb9cd 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -49,7 +49,8 @@ const elements = { lobbyPeerStatus: document.getElementById('lobbyPeerStatus'), browserNotifications: document.getElementById('browserNotifications'), autoCopyInvite: document.getElementById('autoCopyInvite'), - syncTabCopyInvite: document.getElementById('syncTabCopyInvite') + syncTabCopyInvite: document.getElementById('syncTabCopyInvite'), + cancelLobbyBtn: document.getElementById('cancelLobbyBtn') }; let localPeerId = null; @@ -391,18 +392,20 @@ function updatePeerList(peers) { header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding-right: 24px;'; const nameSpan = document.createElement('span'); + nameSpan.style.cssText = 'display: inline-flex; align-items: center; max-width: 200px; overflow: hidden; white-space: nowrap;'; const avatar = getAvatarForName(pUsername || pId); if (pUsername) { const u = document.createElement('span'); - u.style.cssText = 'font-weight:600; color:white;'; + u.style.cssText = 'font-weight:600; color:white; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px; display: inline-block;'; u.textContent = `${avatar} ${pUsername}`; const i = document.createElement('span'); - i.style.cssText = 'font-size:10px; opacity:0.6; font-style:italic;'; + i.style.cssText = 'font-size:10px; opacity:0.6; font-style:italic; white-space: nowrap; flex-shrink: 0;'; i.textContent = ` (${pId})`; nameSpan.appendChild(u); nameSpan.appendChild(i); } else { nameSpan.style.fontWeight = '600'; + nameSpan.style.cssText = 'white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 170px;'; nameSpan.textContent = `${avatar} ${pId}`; } @@ -994,9 +997,14 @@ elements.leaveBtn.addEventListener('click', async () => { }); function handleCreateRoom() { - const generateId = () => Math.random().toString(36).substring(2, 8).toUpperCase(); - const roomId = generateId(); - const password = generateId(); + const secureGenerateId = (length = 6) => { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const array = new Uint8Array(length); + self.crypto.getRandomValues(array); + return Array.from(array, byte => chars[byte % chars.length]).join(''); + }; + const roomId = secureGenerateId(); + const password = secureGenerateId(); elements.roomId.value = roomId; elements.password.value = password; window.justCreatedRoom = true; @@ -1204,6 +1212,21 @@ if (elements.syncTabCopyInvite) { }); } +if (elements.cancelLobbyBtn) { + elements.cancelLobbyBtn.addEventListener('click', () => { + chrome.runtime.sendMessage({ type: 'CANCEL_EPISODE_LOBBY' }, (response) => { + if (response && response.status === 'ok') { + showToast('Episode Lobby skipped.', 'info'); + if (elements.episodeLobbyCard) { + elements.episodeLobbyCard.style.display = 'none'; + } + } else { + showToast('Failed to skip lobby.', 'error'); + } + }); + }); +} + // --- Logs & Status --- async function refreshLogs() { chrome.runtime.sendMessage({ type: 'GET_LOGS' }, (logs) => { diff --git a/server/index.js b/server/index.js index e64ffa5..01e7a4d 100644 --- a/server/index.js +++ b/server/index.js @@ -95,8 +95,24 @@ function checkAuthRate(ip, roomId) { function recordAuthFailure(ip, roomId) { if (failedAuthAttempts.size > 50000) { - failedAuthAttempts.clear(); - log('SECURITY', 'Cleared failedAuthAttempts map to prevent memory leak'); + const now = Date.now(); + // 1. Clear expired entries (> 15 mins) + for (const [key, record] of failedAuthAttempts.entries()) { + if (now - record.lastAttempt > 15 * 60 * 1000) { + failedAuthAttempts.delete(key); + } + } + + // 2. If still over 50k, perform LRU-style eviction on the oldest 10,000 entries + if (failedAuthAttempts.size > 50000) { + log('SECURITY', 'failedAuthAttempts size exceeded 50000. Performing LRU-style eviction.'); + const sortedEntries = Array.from(failedAuthAttempts.entries()) + .sort((a, b) => a[1].lastAttempt - b[1].lastAttempt); + + for (let i = 0; i < 10000 && i < sortedEntries.length; i++) { + failedAuthAttempts.delete(sortedEntries[i][0]); + } + } } const key = `${ip}:${roomId}`; const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 }; @@ -429,7 +445,8 @@ io.on('connection', (socket) => { EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.PEER_STATUS, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_ACK, EVENTS.FORCE_SYNC_EXECUTE, - EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_READY + EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_READY, + EVENTS.EPISODE_LOBBY_CANCEL ]; relayEvents.forEach(eventName => { @@ -501,7 +518,7 @@ io.on('connection', (socket) => { if (!room.activeLobby.readyPeers.includes(mapping.peerId)) { room.activeLobby.readyPeers.push(mapping.peerId); } - } else if ((eventName === EVENTS.FORCE_SYNC_PREPARE || eventName === EVENTS.FORCE_SYNC_EXECUTE) && room.activeLobby) { + } else if ((eventName === EVENTS.FORCE_SYNC_PREPARE || eventName === EVENTS.FORCE_SYNC_EXECUTE || eventName === EVENTS.EPISODE_LOBBY_CANCEL) && room.activeLobby) { room.activeLobby = null; } } diff --git a/shared/constants.js b/shared/constants.js index b924d70..432cf54 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -36,7 +36,8 @@ export const EVENTS = { // Episode Auto-Sync EPISODE_LOBBY: "episode_lobby", // Broadcast: waiting for everyone on this episode - EPISODE_READY: "episode_ready" // Response: loaded the episode and paused at 0:00 + EPISODE_READY: "episode_ready", // Response: loaded the episode and paused at 0:00 + EPISODE_LOBBY_CANCEL: "episode_lobby_cancel" // Broadcast: cancel active lobby and resume }; export const HEARTBEAT_INTERVAL = 15000; // 15s