diff --git a/extension/background.js b/extension/background.js index eeb30ff..c80006e 100644 --- a/extension/background.js +++ b/extension/background.js @@ -15,7 +15,7 @@ let pendingHistory = []; let eventQueue = []; let isNamespaceJoined = false; let lastActionState = { action: null, senderId: null, timestamp: 0, acks: [] }; -let currentCommandSenderId = null; // Track who sent the last command we are executing +let commandSenderMap = new Map(); // tabId -> senderId (tracks who initiated each command per tab) // --- Boot Sequence Lock --- let restorationTask = null; @@ -144,7 +144,7 @@ function createPeerData(raw) { */ function updateLocalPeerState(targetPeerId, updates) { if (!currentRoom || !Array.isArray(currentRoom.peers)) return; - const peer = currentRoom.peers.find(p => (p.peerId || p) === targetPeerId); + const peer = currentRoom.peers.find(p => typeof p === 'object' ? p.peerId === targetPeerId : p === targetPeerId); if (peer && typeof peer === 'object') { Object.keys(updates).forEach(key => { if (updates[key] !== undefined && updates[key] !== null) { @@ -169,22 +169,35 @@ async function getPeerId() { } async function getSettings() { - return new Promise(resolve => { + return new Promise((resolve, reject) => { chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username'], (data) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } let username = data.username; if (!username) { const adjs = ['Happy', 'Cool', 'Fast', 'Smart', 'Brave', 'Calm', 'Sneaky', 'Lazy', 'Wild', 'Chill', 'Lucky', 'Epic', 'Swift', 'Bold', 'Mighty', 'Cosmic', 'Neon', 'Shadow', 'Crystal', 'Thunder', 'Silent', 'Golden', 'Fierce', 'Noble', 'Mystic', 'Frozen', 'Blazing', 'Sapphire', 'Iron', 'Crimson']; const nouns = ['Koala', 'Panda', 'Tiger', 'Eagle', 'Fox', 'Bear', 'Wolf', 'Lion', 'Hawk', 'Seal', 'Owl', 'Shark', 'Dragon', 'Phoenix', 'Falcon', 'Panther', 'Raven', 'Cobra', 'Lynx', 'Jaguar', 'Orca', 'Mantis', 'Viper', 'Condor', 'Badger', 'Otter', 'Rhino', 'Crane', 'Mongoose', 'Specter']; username = `${adjs[Math.floor(Math.random() * adjs.length)]}${nouns[Math.floor(Math.random() * nouns.length)]}`; - chrome.storage.sync.set({ username }); + chrome.storage.sync.set({ username }, () => { + resolve({ + serverUrl: data.serverUrl || '', + useCustomServer: data.useCustomServer || false, + roomId: data.roomId || '', + password: data.password || '', + username: username + }); + }); + } else { + resolve({ + serverUrl: data.serverUrl || '', + useCustomServer: data.useCustomServer || false, + roomId: data.roomId || '', + password: data.password || '', + username: username + }); } - resolve({ - serverUrl: data.serverUrl || '', - useCustomServer: data.useCustomServer || false, - roomId: data.roomId || '', - password: data.password || '', - username: username - }); }); }); } @@ -330,7 +343,11 @@ async function connect() { } else if (msg.startsWith('42')) { try { const payload = JSON.parse(msg.substring(2)); - handleServerEvent(payload[0], payload[1]); + try { + handleServerEvent(payload[0], payload[1]); + } catch (handlerErr) { + addLog(`Handler error for ${payload[0]}: ${handlerErr.message}`, 'error'); + } } catch (_e) { addLog(`Failed to parse message: ${msg}`, 'error'); } @@ -341,7 +358,6 @@ async function connect() { isConnecting = false; isNamespaceJoined = false; - // Clear Force Sync state isForceSyncInitiator = false; forceSyncAcks.clear(); if (forceSyncTimeout) clearTimeout(forceSyncTimeout); @@ -351,7 +367,6 @@ async function connect() { forceSyncDeadline: null }); - // Cancel any active episode lobby clearEpisodeLobbyState(); if (currentRoom) { @@ -360,13 +375,13 @@ async function connect() { } broadcastConnectionStatus('disconnected'); addLog('Disconnected. Scheduling reconnect...', 'warn'); + socket = null; scheduleReconnect(); }; - socket.onerror = (err) => { + socket.onerror = () => { broadcastConnectionStatus('disconnected'); - addLog(`WebSocket Error: ${err.message || 'Handshake failed or server unreachable'}`, 'error'); - socket.close(); + addLog('WebSocket Error: Connection failed', 'error'); }; } catch (e) { @@ -416,7 +431,7 @@ function showNotification(senderName, action) { action === 'force_sync_execute' ? 'synchronized everyone' : action; let displayName = senderName || 'A peer'; - if (currentRoom && currentRoom.peers) { + if (currentRoom && Array.isArray(currentRoom.peers)) { const peer = currentRoom.peers.find(p => (p.peerId || p) === senderName); if (peer && peer.username) displayName = peer.username; } @@ -508,8 +523,13 @@ function handleServerEvent(event, data) { switch (event) { case EVENTS.ROOM_DATA: currentRoom = data; + 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() }); + } else if (currentRoom) { + currentRoom.peers = []; + } if (storageInitialized) chrome.storage.session.set({ currentRoom }); - addLog(`Joined Room: ${data.roomId}`, 'success'); + addLog(`Joined Room: ${data?.roomId || 'unknown'}`, 'success'); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: data.peers }).catch(() => {}); // Inform Website Bridge & Popup @@ -592,7 +612,7 @@ function handleServerEvent(event, data) { } break; case EVENTS.FORCE_SYNC_EXECUTE: - if (data.senderId) { + if (data?.senderId) { addToHistory(event, data.senderId); showNotification(data.senderId, event); @@ -615,7 +635,7 @@ function handleServerEvent(event, data) { routeToContent(event, data); break; case EVENTS.EVENT_ACK: - if (lastActionState && lastActionState.action && data.senderId) { + if (lastActionState && lastActionState.action && data?.senderId) { // Correlation Check: Only accept ACK if it matches our current action's timestamp if (data.actionTimestamp === lastActionState.timestamp) { if (!Array.isArray(lastActionState.acks)) lastActionState.acks = []; @@ -651,7 +671,6 @@ function handleServerEvent(event, data) { if (storageInitialized) chrome.storage.session.set({ currentRoom }); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); - // Episode Lobby: Handle peer departure if (episodeLobby) { checkEpisodeLobbyPeerDeparture(); } @@ -663,8 +682,7 @@ function handleServerEvent(event, data) { } } } else { - // Heartbeat/Update: Update tabTitle for matching - const peer = currentRoom.peers.find(p => (p.peerId || p) === data.peerId); + const peer = currentRoom.peers.find(p => (typeof p === 'object' ? p.peerId : p) === data.peerId); if (peer) { if (typeof peer === 'object') { peer.tabTitle = data.tabTitle; @@ -673,8 +691,6 @@ function handleServerEvent(event, data) { peer.volume = data.volume !== undefined ? data.volume : peer.volume; peer.muted = data.muted !== undefined ? data.muted : peer.muted; - // Race condition guard: ignore heartbeat playbackState/currentTime - // if we applied a reactive user action in the last 1.0 second. const timeSinceReactive = peer.lastReactiveUpdate ? (Date.now() - peer.lastReactiveUpdate) : Infinity; const ignoreStatus = timeSinceReactive < 1000; @@ -822,10 +838,13 @@ function executeEpisodeLobby() { clearEpisodeLobbyState(); addLog(`Episode lobby complete: Starting "${title}" via Force Sync`, 'success'); - // Trigger a standard Force Sync at targetTime 0.0 isForceSyncInitiator = true; forceSyncAcks.clear(); const deadline = Date.now() + 8500; + const timestamp = Date.now(); + updateLastAction(EVENTS.FORCE_SYNC_PREPARE, 'You', timestamp); + lastActionState.targetTime = 0.0; + if (storageInitialized) chrome.storage.session.set({ lastActionState }); chrome.storage.session.set({ isForceSyncInitiator: true, forceSyncAcks: [], @@ -833,8 +852,8 @@ function executeEpisodeLobby() { }); const syncPayload = { targetTime: 0.0 }; - emit(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, peerId }); - routeToContent(EVENTS.FORCE_SYNC_PREPARE, syncPayload); + emit(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, peerId, actionTimestamp: timestamp }); + routeToContent(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, actionTimestamp: timestamp }); forceSyncTimeout = setTimeout(() => { if (isForceSyncInitiator) { @@ -854,6 +873,7 @@ function checkEpisodeLobbyCompletion() { function checkEpisodeLobbyPeerDeparture() { if (!episodeLobby || !currentRoom) return; + if (!Array.isArray(currentRoom.peers)) return; const remainingPeerIds = currentRoom.peers.map(p => typeof p === 'object' ? p.peerId : p); // If only we remain, cancel the lobby @@ -888,8 +908,8 @@ async function routeToContent(action, payload) { const tabId = parseInt(currentTabId); if (isNaN(tabId)) return; - currentCommandSenderId = payload.senderId || null; - const actionTimestamp = payload.actionTimestamp || Date.now(); + commandSenderMap.set(tabId, payload?.senderId || null); + const actionTimestamp = payload?.actionTimestamp || Date.now(); chrome.tabs.sendMessage(tabId, { type: 'SERVER_COMMAND', @@ -897,7 +917,6 @@ async function routeToContent(action, payload) { payload, actionTimestamp }).catch(err => { - // Auto-Reinject if content script is missing or extension was reloaded if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) { chrome.scripting.executeScript({ target: { tabId }, @@ -906,10 +925,12 @@ async function routeToContent(action, payload) { setTimeout(() => routeToContent(action, payload), 500); }).catch(_err => { addLog(`Auto-reinject failed for tab ${tabId}`, 'warn'); + commandSenderMap.delete(tabId); }); } else { addLog(`Content Script not responding in tab ${tabId}`, 'warn'); currentTabId = null; + commandSenderMap.delete(tabId); updateBadgeStatus(); } }); @@ -941,13 +962,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => { function leaveOldRoomIfSwitching(newRoomId) { if (currentRoom && currentRoom.roomId !== newRoomId) { addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info'); - if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) { - try { - socket.send(`42${JSON.stringify([EVENTS.LEAVE_ROOM, { peerId }])}`); - } catch (_e) { - addLog('Failed to send leave room packet during transition', 'error'); - } - } + emit(EVENTS.LEAVE_ROOM, { peerId }); currentRoom = null; if (storageInitialized) chrome.storage.session.set({ currentRoom: null }); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); @@ -1111,7 +1126,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { const processEvent = () => { const timestamp = Date.now(); updateLastAction(message.action, 'You', timestamp); - lastActionState.targetTime = message.payload.targetTime !== undefined ? message.payload.targetTime : message.payload.currentTime; + lastActionState.targetTime = message.payload?.targetTime !== undefined ? message.payload.targetTime : message.payload?.currentTime; if (storageInitialized) chrome.storage.session.set({ lastActionState }); message.payload.actionTimestamp = timestamp; @@ -1185,11 +1200,12 @@ async function handleAsyncMessage(message, sender, sendResponse) { } sendResponse({ status: 'ok' }); } else if (message.type === 'CMD_ACK') { - // Content script successfully ran a command. Send ACK back to the initiator. - if (currentCommandSenderId && currentCommandSenderId !== peerId) { + const tabId = sender.tab ? sender.tab.id : null; + const commandSenderId = tabId ? commandSenderMap.get(tabId) : null; + if (commandSenderId && commandSenderId !== peerId) { emit(EVENTS.EVENT_ACK, { senderId: peerId, - targetId: currentCommandSenderId, + targetId: commandSenderId, actionTimestamp: message.actionTimestamp }); } @@ -1212,21 +1228,25 @@ async function handleAsyncMessage(message, sender, sendResponse) { const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle }; emit(EVENTS.PEER_STATUS, statusPayload); - if (currentRoom && currentRoom.peers) { + if (currentRoom && Array.isArray(currentRoom.peers)) { const me = currentRoom.peers.find(p => (p.peerId || p) === peerId); if (me && typeof me === 'object') { me.tabTitle = currentTabTitle; me.username = settings.username; - me.mediaTitle = message.payload.mediaTitle; - me.playbackState = message.payload.playbackState; - me.currentTime = message.payload.currentTime; - me.volume = message.payload.volume; - me.muted = message.payload.muted; + me.mediaTitle = message.payload?.mediaTitle; + me.playbackState = message.payload?.playbackState; + me.currentTime = message.payload?.currentTime; + me.volume = message.payload?.volume; + me.muted = message.payload?.muted; me.lastHeartbeat = Date.now(); + if (storageInitialized) chrome.storage.session.set({ currentRoom }); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); } } sendResponse({ status: 'ok' }); + }).catch(err => { + addLog('Heartbeat settings error: ' + err.message, 'error'); + sendResponse({ status: 'ok' }); }); } else if (message.type === 'SET_TARGET_TAB') { currentTabId = message.tabId; diff --git a/extension/content.js b/extension/content.js index 7538f80..60c5de3 100644 --- a/extension/content.js +++ b/extension/content.js @@ -47,10 +47,14 @@ function expectEvent(state) { expectedEvents.add(state); - if (expectedTimeouts[state]) clearTimeout(expectedTimeouts[state]); + if (expectedTimeouts[state]) { + clearTimeout(expectedTimeouts[state]); + delete expectedTimeouts[state]; + } const timeout = state === 'seek' ? 10000 : 1500; expectedTimeouts[state] = setTimeout(() => { expectedEvents.delete(state); + delete expectedTimeouts[state]; }, timeout); } @@ -59,9 +63,16 @@ } // --- Helper: find the best video element on the page --- - function findVideo() { - const videos = document.querySelectorAll('video'); - return videos.length > 0 ? videos[0] : null; + function findVideo(root = document) { + const video = root.querySelector('video'); + if (video) return video; + for (const el of root.querySelectorAll('*')) { + if (el.shadowRoot) { + const found = findVideo(el.shadowRoot); + if (found) return found; + } + } + return null; } // --- Episode Auto-Sync: Detection --- @@ -169,7 +180,7 @@ reportLog(`Media Action Error: Invalid seek payload - ${JSON.stringify(data)}`, 'error'); return; } - data.targetTime = target; + data = { ...data, targetTime: target }; } try { @@ -259,7 +270,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action === 'get_current_time') { const video = findVideo(); - sendResponse({ currentTime: video ? video.currentTime : undefined }); + sendResponse({ currentTime: video ? video.currentTime : null }); return true; } @@ -292,13 +303,13 @@ video.pause(); video.currentTime = payload.targetTime; pollSeekReady(payload.targetTime).then((ready) => { - chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' }); + chrome.runtime.sendMessage({ type: 'FORCE_SYNC_ACK' }).catch(() => {}); if (ready) { scheduleProactiveHeartbeat(); } else { reportLog('Force Sync: Seek ready timeout, proceeding anyway', 'warn'); } - }); + }).catch(() => {}); } } else if (action === EVENTS.FORCE_SYNC_EXECUTE) { stopLobbyPoll(); // Clear any pending lobby on force sync @@ -471,7 +482,7 @@ }; - let lastVideoSrc = null; + let lastVideoSrc = undefined; // Episode detection handler for loadeddata event const handleLoadedData = () => { @@ -481,19 +492,22 @@ function setupListeners() { const video = findVideo(); if (video) { - video.removeEventListener('play', handlePlay); - video.removeEventListener('pause', handlePause); - video.removeEventListener('seeked', handleSeeked); - video.removeEventListener('loadeddata', handleLoadedData); + const existing = video._koalaHandlers; + if (existing) { + video.removeEventListener('play', existing.play); + video.removeEventListener('pause', existing.pause); + video.removeEventListener('seeked', existing.seeked); + video.removeEventListener('loadeddata', existing.loadeddata); + } + video._koalaHandlers = { play: handlePlay, pause: handlePause, seeked: handleSeeked, loadeddata: handleLoadedData }; video.addEventListener('play', handlePlay); video.addEventListener('pause', handlePause); video.addEventListener('seeked', handleSeeked); video.addEventListener('loadeddata', handleLoadedData); video.dataset.koalaAttached = 'true'; - lastVideoSrc = video.currentSrc || video.src; + lastVideoSrc = video.currentSrc || video.src || null; - // Initialize episode tracking title on first attach if (!lastKnownMediaTitle) { lastKnownMediaTitle = getMediaTitle(); } @@ -508,19 +522,18 @@ lastMutate = Date.now(); const video = findVideo(); - if (!video && lastVideoSrc) { + if (!video && lastVideoSrc !== undefined) { reportLog('Video element removed from page', 'warn'); - lastVideoSrc = null; + lastVideoSrc = undefined; return; } if (!video) return; - const currentSrc = video.currentSrc || video.src; + const currentSrc = video.currentSrc || video.src || null; - if (!video.dataset.koalaAttached || (lastVideoSrc && currentSrc && lastVideoSrc !== currentSrc)) { - // If src changed, also check for episode transition - if (lastVideoSrc && currentSrc && lastVideoSrc !== currentSrc) { + if (!video.dataset.koalaAttached || (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc)) { + if (lastVideoSrc !== undefined && currentSrc && lastVideoSrc !== currentSrc) { checkEpisodeTransition(); } setupListeners(); @@ -599,6 +612,7 @@ // Episode Auto-Sync: Boot recovery — check if background has an active lobby chrome.runtime.sendMessage({ type: 'CONTENT_BOOT' }, (res) => { + if (chrome.runtime.lastError) return; if (res && res.lobbyActive && res.expectedTitle) { reportLog(`Boot: Active lobby detected for "${res.expectedTitle}"`, 'info'); startLobbyPoll(res.expectedTitle); diff --git a/extension/popup.js b/extension/popup.js index 9fda798..fe58668 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -53,6 +53,10 @@ let localPeerId = null; let lastPeersJson = null; let lastKnownPeers = []; let isDevTabVisible = false; +let joinBtnTimeout = null; +let popupIntervals = []; +let populateTabsToken = null; +let forceSyncDone = false; // --- Initialization --- async function init() { @@ -94,6 +98,11 @@ async function init() { // Initial Status Check chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => { + if (chrome.runtime.lastError) { + console.warn('[Popup] Background not responding:', chrome.runtime.lastError.message); + await populateTabs(); + return; + } if (res) { localPeerId = res.peerId; applyConnectionStatus(res.status); @@ -117,7 +126,7 @@ async function init() { chrome.runtime.sendMessage({ type: 'GET_ROOM_LIST' }); // Debug Info Refresh - setInterval(refreshDebugInfo, 2000); + popupIntervals.push(setInterval(refreshDebugInfo, 2000)); // Show onboarding on first visit chrome.storage.sync.get(['onboardingComplete'], (data) => { @@ -147,6 +156,13 @@ function updateUI(roomId, password, useCustomServer = false, serverUrl = '') { } } else { updatePeerList([]); + if (elements.inviteLink) elements.inviteLink.value = ''; + if (elements.activeRoomId) elements.activeRoomId.textContent = ''; + if (elements.activeServer) { + elements.activeServer.textContent = ''; + elements.activeServer.title = ''; + } + lastKnownPeers = []; } } @@ -171,7 +187,8 @@ function updateLastActionUI(state, peers) { const senderPeer = safePeers.find(p => (p.peerId || p) === state.senderId); if (senderPeer && senderPeer.username) senderName = senderPeer.username; - const timeStr = new Date(state.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + const ts = state.timestamp ? new Date(state.timestamp) : new Date(); + const timeStr = isNaN(ts.getTime()) ? '--:--' : ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); elements.lastActionCard.innerHTML = ''; @@ -236,7 +253,7 @@ function updateLastActionUI(state, peers) { } function formatTime(seconds) { - if (seconds === null || seconds === undefined || isNaN(seconds)) return '--:--'; + if (seconds === null || seconds === undefined || isNaN(seconds) || seconds < 0) return '--:--'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); @@ -453,26 +470,44 @@ function detectPeerChanges(newPeers) { } async function populateTabs(providedPeers = null, providedTargetTabId = null) { + const token = {}; + populateTabsToken = token; + const data = await chrome.storage.sync.get(['filterNoise']); const isFilterActive = data.filterNoise !== false; - // Fallback if not provided directly let currentTargetTabId = providedTargetTabId; if (currentTargetTabId === null) { const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r)); - currentTargetTabId = status?.targetTabId; + if (chrome.runtime.lastError) { + if (populateTabsToken !== token) return; + currentTargetTabId = null; + } else { + currentTargetTabId = status?.targetTabId; + } } - // Use provided peers or fetch if missing let peerIds = providedPeers; if (!peerIds) { const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r)); - peerIds = status?.peers || []; + if (chrome.runtime.lastError) { + if (populateTabsToken !== token) return; + peerIds = []; + } else { + peerIds = status?.peers || []; + } } - const tabs = await chrome.tabs.query({}); + let tabs = []; + try { + tabs = await chrome.tabs.query({}); + } catch (e) { + console.warn('[Popup] tabs.query failed:', e.message); + if (populateTabsToken !== token) return; + } - // Clear existing options except placeholder + if (!elements.targetTab) return; + if (populateTabsToken !== token) return; while (elements.targetTab.options.length > 1) { elements.targetTab.remove(1); } @@ -548,38 +583,44 @@ function applyConnectionStatus(status) { const reconnecting = status === 'reconnecting'; const failed = status === 'reconnect_failed'; - elements.connDot.className = 'status-dot ' + (connected ? 'status-online' : (failed ? 'status-offline' : ((connecting || reconnecting) ? 'status-online' : 'status-offline'))); - - if (reconnecting) { - elements.connDot.style.background = '#f59e0b'; - elements.connDot.style.boxShadow = '0 0 8px #f59e0b'; - } else if (connecting) { - elements.connDot.style.background = '#fbbf24'; - elements.connDot.style.boxShadow = '0 0 8px #fbbf24'; - } else if (failed) { - elements.connDot.style.background = '#ef4444'; - elements.connDot.style.boxShadow = 'none'; - } else { - elements.connDot.style.background = ''; - elements.connDot.style.boxShadow = ''; + if (elements.connDot) { + elements.connDot.className = 'status-dot ' + (connected ? 'status-online' : (failed ? 'status-offline' : ((connecting || reconnecting) ? 'status-online' : 'status-offline'))); + + if (reconnecting) { + elements.connDot.style.background = '#f59e0b'; + elements.connDot.style.boxShadow = '0 0 8px #f59e0b'; + } else if (connecting) { + elements.connDot.style.background = '#fbbf24'; + elements.connDot.style.boxShadow = '0 0 8px #fbbf24'; + } else if (failed) { + elements.connDot.style.background = '#ef4444'; + elements.connDot.style.boxShadow = 'none'; + } else { + elements.connDot.style.background = ''; + elements.connDot.style.boxShadow = ''; + } } - elements.connText.textContent = connected ? 'Connected' : (reconnecting ? 'Reconnecting...' : (connecting ? 'Connecting...' : (failed ? 'Failed' : 'Disconnected'))); - elements.retryBtn.style.display = failed ? 'block' : 'none'; - - // Update Join Button during auto-transition - if (connecting || reconnecting) { - elements.joinBtn.disabled = true; - elements.joinBtn.textContent = connecting ? '🚀 Joining...' : '🔄 Reconnecting...'; - } else { - elements.joinBtn.disabled = false; - elements.joinBtn.textContent = 'Join Room'; + if (elements.connText) { + elements.connText.textContent = connected ? 'Connected' : (reconnecting ? 'Reconnecting...' : (connecting ? 'Connecting...' : (failed ? 'Failed' : 'Disconnected'))); + } + if (elements.retryBtn) { + elements.retryBtn.style.display = failed ? 'block' : 'none'; } - // Preserve icons for Remote Control buttons - elements.playBtn.textContent = '▶ Play'; - elements.pauseBtn.textContent = '⏸ Pause'; - elements.forceSyncBtn.textContent = '⚡ Force Sync'; + if (elements.joinBtn) { + if (connecting || reconnecting) { + elements.joinBtn.disabled = true; + elements.joinBtn.textContent = connecting ? '🚀 Joining...' : '🔄 Reconnecting...'; + } else { + elements.joinBtn.disabled = false; + elements.joinBtn.textContent = 'Join Room'; + } + } + + if (elements.playBtn) elements.playBtn.textContent = '▶ Play'; + if (elements.pauseBtn) elements.pauseBtn.textContent = '⏸ Pause'; + if (elements.forceSyncBtn) elements.forceSyncBtn.textContent = '⚡ Force Sync'; } function updateHistory(history) { @@ -682,39 +723,42 @@ function checkInviteLink() { chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { const tab = tabs[0]; if (tab && tab.url && tab.url.includes(OFFICIAL_LANDING_PAGE_URL) && tab.url.includes('#join:')) { - const rawHash = tab.url.split('#join:')[1]; - const parts = rawHash.split(':'); - if (parts.length >= 2) { - const roomId = parts.shift(); - let useCustomServer = false; - let serverUrl = ''; + try { + const rawHash = tab.url.split('#join:')[1]; + if (!rawHash) return; + const parts = rawHash.split(':'); + if (parts.length >= 2) { + const roomId = parts.shift(); + let useCustomServer = false; + let serverUrl = ''; - // Smart Link: Parse Server Config if present - const last = parts[parts.length - 1]; - const secondToLast = parts[parts.length - 2]; - const decodedLast = decodeURIComponent(last || ''); - const isCustom = secondToLast === '1' && (decodedLast.startsWith('ws://') || decodedLast.startsWith('wss://')); - const isOfficial = secondToLast === '0' && last === ''; + const last = parts[parts.length - 1]; + const secondToLast = parts[parts.length - 2]; + const decodedLast = decodeURIComponent(last || ''); + const isCustom = secondToLast === '1' && (decodedLast.startsWith('ws://') || decodedLast.startsWith('wss://')); + const isOfficial = secondToLast === '0' && last === ''; - if (parts.length >= 3 && (isCustom || isOfficial)) { - serverUrl = decodeURIComponent(parts.pop()); - useCustomServer = parts.pop() === '1'; + if (parts.length >= 3 && (isCustom || isOfficial)) { + serverUrl = decodeURIComponent(parts.pop()); + useCustomServer = parts.pop() === '1'; + } + + const password = parts.join(':'); + + elements.roomId.value = roomId; + elements.password.value = password; + + if (serverUrl || useCustomServer) { + elements.serverUrl.value = serverUrl; + setServerMode(useCustomServer); + chrome.storage.sync.set({ serverUrl, useCustomServer }); + } + + elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)'; + setTimeout(() => elements.joinBtn.style.boxShadow = '', 2000); } - - const password = parts.join(':'); - - elements.roomId.value = roomId; - elements.password.value = password; - - if (serverUrl || useCustomServer) { - elements.serverUrl.value = serverUrl; - setServerMode(useCustomServer); - chrome.storage.sync.set({ serverUrl, useCustomServer }); - } - - // Visual feedback - elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)'; - setTimeout(() => elements.joinBtn.style.boxShadow = '', 2000); + } catch (_e) { + // Malformed invite link, ignore } } }); @@ -724,7 +768,11 @@ function setServerMode(custom) { elements.serverOfficial.classList.toggle('active', !custom); elements.serverCustom.classList.toggle('active', custom); elements.serverUrl.style.display = custom ? 'block' : 'none'; - chrome.storage.sync.set({ useCustomServer: custom }); + chrome.storage.sync.get(['useCustomServer'], (data) => { + if (data.useCustomServer !== custom) { + chrome.storage.sync.set({ useCustomServer: custom }); + } + }); } elements.serverOfficial.addEventListener('click', () => setServerMode(false)); @@ -812,6 +860,14 @@ elements.joinBtn.addEventListener('click', async () => { elements.joinBtn.disabled = true; elements.joinBtn.textContent = isCreating ? 'Creating Room...' : 'Joining...'; + if (joinBtnTimeout) clearTimeout(joinBtnTimeout); + joinBtnTimeout = setTimeout(() => { + elements.joinBtn.disabled = false; + elements.joinBtn.textContent = 'Join Room'; + joinBtnTimeout = null; + showError('Connection timed out. Please try again.'); + }, 15000); + const serverUrl = elements.serverUrl.value.trim(); const useCustom = elements.serverCustom.classList.contains('active'); @@ -846,6 +902,7 @@ elements.leaveBtn.addEventListener('click', async () => { await chrome.storage.sync.set({ roomId: '', password: '' }); elements.roomId.value = ''; elements.password.value = ''; + lastKnownPeers = []; updateUI(null, null); }); @@ -882,7 +939,7 @@ elements.forceSyncBtn.addEventListener('click', async () => { if (elements.forceSyncBtn.disabled) return; const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r)); - if (!status || !status.targetTabId) return; + if (chrome.runtime.lastError || !status || !status.targetTabId) return; const mode = elements.forceSyncMode.value; let targetTime = null; @@ -910,10 +967,14 @@ elements.forceSyncBtn.addEventListener('click', async () => { const originalText = elements.forceSyncBtn.textContent; elements.forceSyncBtn.disabled = true; elements.forceSyncBtn.textContent = mode === 'jump-to-others' ? `Syncing to group (${formatTime(targetTime)})...` : 'Syncing...'; - setTimeout(() => { - elements.forceSyncBtn.disabled = false; - elements.forceSyncBtn.textContent = originalText; - }, 5000); + forceSyncDone = false; + const forceSyncReset = () => { + if (!forceSyncDone) { + elements.forceSyncBtn.disabled = false; + elements.forceSyncBtn.textContent = originalText; + } + }; + setTimeout(forceSyncReset, 12000); const tabId = parseInt(status.targetTabId); @@ -934,6 +995,7 @@ elements.forceSyncBtn.addEventListener('click', async () => { }).then(() => { setTimeout(() => { chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => { + if (chrome.runtime.lastError) return; if (retryResponse && retryResponse.currentTime !== undefined) { sendForceSync(retryResponse.currentTime); } @@ -941,6 +1003,9 @@ elements.forceSyncBtn.addEventListener('click', async () => { }, 500); }).catch(() => { showError('Could not connect to video tab.'); + forceSyncDone = true; + elements.forceSyncBtn.disabled = false; + elements.forceSyncBtn.textContent = originalText; }); return; } @@ -985,6 +1050,8 @@ elements.copyInvite.addEventListener('click', () => { elements.copyInvite.style.background = ''; elements.copyInvite.style.color = ''; }, 2000); + }).catch(() => { + showToast('Failed to copy to clipboard', 'error'); }); }); @@ -1027,6 +1094,13 @@ chrome.runtime.onMessage.addListener((msg) => { const action = actionNames[state.action] || state.action; showToast(`${state.senderId} ${action}`, 'info', 2000); } + if (state && state.action === 'force_sync_execute') { + forceSyncDone = true; + if (elements.forceSyncBtn) { + elements.forceSyncBtn.disabled = false; + elements.forceSyncBtn.textContent = '⚡ Force Sync'; + } + } chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => { if (res && res.peers) updateLastActionUI(msg.state, res.peers); }); @@ -1047,8 +1121,11 @@ chrome.runtime.onMessage.addListener((msg) => { } if (msg.status === 'reconnecting') { chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => { + if (chrome.runtime.lastError) return; if (res && res.reconnectAttempts !== undefined) { - elements.connText.textContent = `Reconnecting... (${res.reconnectAttempts})`; + if (elements.connText) { + elements.connText.textContent = `Reconnecting... (${res.reconnectAttempts})`; + } } }); } @@ -1086,6 +1163,8 @@ elements.copyLogs.addEventListener('click', () => { const original = elements.copyLogs.textContent; elements.copyLogs.textContent = 'Copied!'; setTimeout(() => elements.copyLogs.textContent = original, 2000); + }).catch(() => { + showToast('Failed to copy to clipboard', 'error'); }); }); }); @@ -1168,12 +1247,18 @@ function refreshDebugInfo() { } init(); -setInterval(() => { +popupIntervals.push(setInterval(() => { if (isDevTabVisible) refreshLogs(); -}, 5000); +}, 5000)); window.addEventListener('unload', () => { stopInterpolation(); + popupIntervals.forEach(clearInterval); + popupIntervals = []; + if (joinBtnTimeout) { + clearTimeout(joinBtnTimeout); + joinBtnTimeout = null; + } }); // --- Episode Lobby UI --- diff --git a/server/index.js b/server/index.js index 56f77ff..64be368 100644 --- a/server/index.js +++ b/server/index.js @@ -43,10 +43,16 @@ const httpServer = createServer(app); // Socket.IO setup with security constraints const io = new Server(httpServer, { cors: { - origin: ["https://sync.koalastuff.net"], + origin: (origin, callback) => { + if (!origin || origin === 'https://sync.koalastuff.net' || origin.startsWith('chrome-extension://')) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, methods: ["GET", "POST"] }, - maxHttpBufferSize: 1024, // 1KB max per message + maxHttpBufferSize: 4096, // 4KB max per message (headroom for JOIN_ROOM payloads) transports: ['websocket'], allowUpgrades: false }); @@ -57,6 +63,7 @@ const io = new Server(httpServer, { const rooms = new Map(); const socketToRoom = new Map(); const peerToSocket = new Map(); // peerId -> socketId (Global lookup) +const roomCreationLocks = new Map(); // roomId -> Promise (prevents race on room creation) function log(type, message, details = '') { const timestamp = new Date().toISOString(); @@ -93,15 +100,15 @@ function recordAuthFailure(ip, roomId) { failedAuthAttempts.set(key, record); } -// Periodically clean up old auth failure records (every hour) +// Periodically clean up old auth failure records (every 15 minutes) setInterval(() => { const now = Date.now(); for (const [key, record] of failedAuthAttempts.entries()) { - if (now - record.lastAttempt > 60 * 60 * 1000) { + if (now - record.lastAttempt > 15 * 60 * 1000) { failedAuthAttempts.delete(key); } } -}, 60 * 60 * 1000); +}, 15 * 60 * 1000); const eventCounts = new Map(); // socketId -> { count, resetTime } const healthCounts = new Map(); // ip -> { count, resetTime } @@ -115,7 +122,7 @@ setInterval(() => { } } for (const [socketId, entry] of eventCounts.entries()) { - if (now > entry.resetTime) { + if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) { eventCounts.delete(socketId); } } @@ -177,7 +184,8 @@ function removePeerFromRoom(socketId, roomId, reason) { // 2. Remove from global maps socketToRoom.delete(socketId); - if (peerToSocket.get(peerId) === socketId) { + const currentSocketId = peerToSocket.get(peerId); + if (currentSocketId === socketId) { peerToSocket.delete(peerId); } @@ -195,7 +203,9 @@ function removePeerFromRoom(socketId, roomId, reason) { } io.on('connection', (socket) => { - const clientIp = socket.handshake.address; + // Get real client IP behind proxy/CDN + const forwardedFor = socket.handshake.headers['x-forwarded-for']; + const clientIp = forwardedFor ? forwardedFor.split(',')[0].trim() : socket.handshake.address; // 1. Connection Rate Limit if (!checkConnectionRate(clientIp)) { @@ -216,7 +226,8 @@ io.on('connection', (socket) => { } if (clientVersion) { - const [cMaj, cMin, cPatch] = clientVersion.split('.').map(Number); + const parts = clientVersion.split('.').map(Number); + const cMaj = parts[0], cMin = parts[1], cPatch = parts[2] || 0; const [mMaj, mMin, mPatch] = MIN_VERSION.split('.').map(Number); if (isNaN(cMaj) || isNaN(cMin) || isNaN(cPatch)) { log('AUTH', `Invalid version format (${clientVersion}) from ${clientIp}`); @@ -264,8 +275,8 @@ io.on('connection', (socket) => { // Cleanup old room if re-joining const oldMapping = socketToRoom.get(socket.id); - if (oldMapping && oldMapping.roomId === roomId) { - return; // Already in this room, ignore to prevent spam + if (oldMapping && oldMapping.roomId === roomId && oldMapping.peerId === peerId) { + return; // Already in this room with same peerId, ignore to prevent spam } if (oldMapping && oldMapping.roomId !== roomId) { socket.leave(oldMapping.roomId); @@ -281,21 +292,32 @@ io.on('connection', (socket) => { let room = rooms.get(roomId); if (!room) { - if (rooms.size >= MAX_ROOMS) { - socket.emit(EVENTS.ERROR, { message: "Server capacity reached" }); - return; + // Acquire per-room creation lock to prevent race conditions + let lockPromise = roomCreationLocks.get(roomId); + if (lockPromise) { + await lockPromise; + room = rooms.get(roomId); + if (room) { + // Another concurrent request created it, fall through to password check + } } + if (!room) { + if (rooms.size >= MAX_ROOMS) { + socket.emit(EVENTS.ERROR, { message: "Server capacity reached" }); + return; + } - const passwordHash = password ? await bcrypt.hash(password, 10) : null; - room = { - passwordHash, - peers: new Set(), - peerIds: new Map(), - peerData: new Map(), // socketId -> { peerId, tabTitle } - lastActivity: Date.now() - }; - rooms.set(roomId, room); - log('ROOM', `Created room: ${roomId.substring(0, 3)}***`); + const passwordHash = password ? await bcrypt.hash(password, 10) : null; + room = { + passwordHash, + peers: new Set(), + peerIds: new Map(), + peerData: new Map(), + lastActivity: Date.now() + }; + rooms.set(roomId, room); + log('ROOM', `Created room: ${roomId.substring(0, 3)}***`); + } } else { if (room.passwordHash) { if (!password || !(await bcrypt.compare(password, room.passwordHash))) { @@ -310,7 +332,6 @@ io.on('connection', (socket) => { } // Peer Deduplication: Remove existing socket for the same peerId - // Snapshot stale SIDs first to avoid mutating the Map during iteration const dedupeSids = []; for (const [sid, data] of room.peerData.entries()) { if (data.peerId === peerId && sid !== socket.id) { @@ -318,6 +339,10 @@ io.on('connection', (socket) => { } } for (const sid of dedupeSids) { + // Re-check: the socket might have been replaced by another concurrent join + const currentMapping = room.peerData.get(sid); + if (!currentMapping || currentMapping.peerId !== peerId) continue; + const oldSocket = io.sockets.sockets.get(sid); if (oldSocket) { oldSocket.emit(EVENTS.ERROR, { message: 'Deduplication: Another session with this ID joined. Disconnecting...' }); @@ -350,7 +375,9 @@ io.on('connection', (socket) => { log('ROOM', `Peer ${peerId} joined: ${roomId.substring(0, 3)}***`); } catch (err) { log('ERROR', `Join error for ${socket.id}`, err); - socket.emit(EVENTS.ERROR, { message: "Join error" }); + if (socket.connected) { + socket.emit(EVENTS.ERROR, { message: "Join error" }); + } } }); @@ -364,18 +391,19 @@ io.on('connection', (socket) => { relayEvents.forEach(eventName => { socket.on(eventName, (data) => { - if (!checkEventRate(socket.id)) { - log('SECURITY', `Event rate limit exceeded for socket: ${socket.id}`); - socket.disconnect(true); - return; - } + try { + if (!checkEventRate(socket.id)) { + log('SECURITY', `Event rate limit exceeded for socket: ${socket.id}`); + socket.disconnect(true); + return; + } - if (!data || typeof data !== 'object') return; // Prevent null/invalid payload crash + if (!data || typeof data !== 'object') return; - const mapping = socketToRoom.get(socket.id); - if (mapping) { - const room = rooms.get(mapping.roomId); - if (room) { + const mapping = socketToRoom.get(socket.id); + if (mapping) { + const room = rooms.get(mapping.roomId); + if (room) { room.lastActivity = Date.now(); // --- S-2 & S-3: Sanitize ALL relay fields (strings, numbers, booleans) --- @@ -417,7 +445,10 @@ io.on('connection', (socket) => { // Strip undefined keys for clean wire format Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]); socket.to(mapping.roomId).emit(eventName, relayPayload); + } } + } catch (err) { + log('ERROR', `Relay handler error for ${eventName}: ${err.message}`); } }); }); @@ -482,7 +513,11 @@ setInterval(() => { const roomCutoff = now - (2 * 60 * 60 * 1000); // 2 hours const peerCutoff = now - (5 * 60 * 1000); // 5 minutes - for (const [roomId, room] of rooms) { + // Snapshot room keys to avoid mutation during iteration + const roomIds = Array.from(rooms.keys()); + for (const roomId of roomIds) { + const room = rooms.get(roomId); + if (!room) continue; // Room may have been deleted between snapshot and now // 1. Prune dead peers // Snapshot keys first — we must not mutate peerData while iterating it. const staleSids = []; @@ -494,14 +529,15 @@ setInterval(() => { for (const sid of staleSids) { // Gracefully evict the socket from the Socket.IO room if it is // still technically connected (zombie with no heartbeat). - const deadSocket = io.sockets.sockets.get(sid); + const deadSocket = io.sockets?.sockets?.get(sid); if (deadSocket) deadSocket.leave(roomId); log('CLEANUP', `Pruning dead peer from room ${roomId.substring(0, 3)}***`); removePeerFromRoom(sid, roomId, 'reaper'); } // 2. Prune empty or inactive rooms - if (room.peers.size === 0 || room.lastActivity < roomCutoff) { + const currentRoom = rooms.get(roomId); + if (currentRoom && (currentRoom.peers.size === 0 || currentRoom.lastActivity < roomCutoff)) { io.to(roomId).emit(EVENTS.ERROR, { message: 'Room closed' }); rooms.delete(roomId); log('CLEANUP', `Deleted room ${roomId.substring(0, 3)}*** (Empty/Inactive)`); @@ -532,3 +568,11 @@ function gracefulShutdown(signal) { process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); process.on('SIGINT', () => gracefulShutdown('SIGINT')); + +process.on('uncaughtException', (err) => { + log('ERROR', `Uncaught exception: ${err.message}`, err.stack); +}); + +process.on('unhandledRejection', (reason) => { + log('ERROR', `Unhandled rejection: ${reason}`); +});