fix: v1.7.0 - critical bug fixes, race conditions, memory leaks, null guards, server hardening

This commit is contained in:
Koala
2026-05-25 11:48:54 +02:00
parent f59d30569e
commit 284b82a910
4 changed files with 348 additions and 185 deletions
+69 -49
View File
@@ -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;
+35 -21
View File
@@ -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);
+161 -76
View File
@@ -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 ---
+83 -39
View File
@@ -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}`);
});