import { EVENTS, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js'; import { generateUsername } from './shared/names.js'; import { loadLocale, getMessage, getSystemLanguage } from './i18n.js'; import { sameEpisode, extractEpisodeId } from './episode-utils.js'; import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js'; import { initTabManager } from './modules/tab-manager.js'; import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.js'; import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js'; import { createChatSendLimiter, createLatestTaskQueue, normalizeRoomId } from './chat-session.js'; import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js'; import './page-api-seek-overrides.js'; // --- Uninstall URL Initialization --- let uninstallURLInitPromise = null; async function initUninstallURL() { if (uninstallURLInitPromise) { return uninstallURLInitPromise; } uninstallURLInitPromise = (async () => { // --- UNINSTALL_URL_INJECT_START --- const UNINSTALL_URL = ""; // Populated during build const BROWSER_TYPE = "unknown"; // --- UNINSTALL_URL_INJECT_END --- if (UNINSTALL_URL && UNINSTALL_URL.trim() !== '') { try { const url = new URL(UNINSTALL_URL); url.searchParams.set("browser", BROWSER_TYPE); const runtimeAPI = typeof browser !== 'undefined' ? browser.runtime : chrome.runtime; if (runtimeAPI && runtimeAPI.setUninstallURL) { const result = runtimeAPI.setUninstallURL(url.href); // browser.runtime.setUninstallURL returns a Promise, handle rejection silently if (result && typeof result.catch === 'function') { result.catch(err => console.warn('Failed to set uninstall URL:', err)); } } } catch (err) { console.error("Failed to initialize uninstall URL:", err); } } })(); return uninstallURLInitPromise; } chrome.runtime.onInstalled.addListener((details) => { if (details.reason === 'install' || details.reason === 'update') { initUninstallURL(); purgeLegacySyncKeys(); } }); chrome.runtime.onStartup.addListener(() => { initUninstallURL(); purgeLegacySyncKeys(); }); // --- State Management --- let socket = null; let isConnecting = false; let peerId = null; // initialized via getPeerId() let currentRoom = null; let currentTabId = null; let currentTabTitle = null; // New: for Smart Matching let targetActivationGeneration = 0; let activeTargetActivation = null; let logs = []; let history = []; // New: for Action History let storageInitialized = false; let pendingLogs = []; let pendingHistory = []; let eventQueue = []; let flushTimer = null; // paces draining of eventQueue after (re)connect let isNamespaceJoined = false; let lastActionState = { action: null, senderId: null, timestamp: 0, acks: [] }; let localSeq = 0; // Monotonically increasing command sequence for this peer const lastSeqBySender = {}; // senderId → last received seq (stale command guard) // --- Host Control Mode --- let controlMode = CONTROL_MODES.EVERYONE; // 'everyone' | 'host-only' let hostPeerId = null; // peerId of the room host (creator / fallback) // Features the connected relay advertises in ROOM_DATA. Empty against an older // relay (no capabilities field) → host-control UI/behavior stays unavailable. let serverCapabilities = []; let chatSecretGuard = ''; let chatSessionGeneration = 0; let chatReceiveQueue = Promise.resolve(); const chatSendLimiter = createChatSendLimiter(); const webJoinCoordinator = createLatestTaskQueue(); function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); } function serverSupportsChat() { return serverSupports(CAPABILITIES.CHAT_V1) || serverSupports(CAPABILITIES.CHAT); } const CLIENT_CAPABILITIES = Object.freeze([CAPABILITIES.CHAT_V1]); function invalidateChatSession() { chatSessionGeneration++; chatReceiveQueue = Promise.resolve(); chatSendLimiter.reset(); clearChatKeyCache(); } async function clearFailedJoinCredentials() { webJoinCoordinator.invalidate(); connectIntent = false; chatSecretGuard = ''; invalidateChatSession(); await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {}); } // Local peer's desync state (content.js reports it via HCM_DESYNC_STATE). Relayed // in heartbeats so the host's popup UI can show "Solo" instead of silently // appearing un-ACK'd. let hcmDesynced = false; // Co-Host: peerIds allowed to drive in host-only (always includes the owner). let controllers = []; function amHost() { return !!peerId && hostPeerId === peerId; } // owner: can toggle mode / promote function amController() { return amHost() || (!!peerId && controllers.includes(peerId)); } // can drive the room // Room-moving actions a guest may not initiate while in host-only mode. const HOST_ONLY_GATED_ACTIONS = [ EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE, EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_LOBBY_CANCEL ]; // Best-effort estimate of where the room (host) is right now, for guest snap-back. // Extrapolates from the host peer's last known state. Used by content.js. function getHostSyncTarget() { if (!currentRoom || !Array.isArray(currentRoom.peers)) return null; const host = currentRoom.peers.find(p => (typeof p === 'object' ? p.peerId : p) === hostPeerId); if (!host || typeof host !== 'object') return null; let targetTime = typeof host.currentTime === 'number' ? host.currentTime : null; if (targetTime !== null && host.playbackState === 'playing' && host.lastHeartbeat) { // M-4: clamp extrapolation. lastHeartbeat is the *arrival* time of the host's // last heartbeat — beyond ~2 heartbeat intervals the host's true state is too // stale (they may have paused without the next heartbeat landing yet) and the // linear extrapolation would overshoot by tens of seconds. Cap it so the // guest snaps to a position within plausibility; the next heartbeat corrects. const elapsedSec = (Date.now() - host.lastHeartbeat) / 1000; if (elapsedSec > 0 && elapsedSec <= 2 * HEARTBEAT_INTERVAL / 1000) { targetTime += elapsedSec; } } return { playbackState: host.playbackState || null, targetTime }; } const activePorts = new Set(); // New: track active content ports for keep-alive let expectedAcksCount = 0; // Snapshot of peerCount when initiating Force Sync // --- Ping / Latency --- let pingInterval = null; let pingTimeout = null; let pendingPingT = null; let currentPingMs = null; let missedPongs = 0; // --- Keep-Alive Port Listener --- chrome.runtime.onConnect.addListener((port) => { if (port.name === 'keepAlive') { activePorts.add(port); port.onDisconnect.addListener(() => { activePorts.delete(port); }); } }); let _persistLastSeqTimer = null; function _persistLastSeq() { if (!storageInitialized) return; if (_persistLastSeqTimer) clearTimeout(_persistLastSeqTimer); _persistLastSeqTimer = setTimeout(() => { _persistLastSeqTimer = null; chrome.storage.session.set({ lastSeqBySender }); }, 500); } // --- Boot Sequence Lock --- let restorationTask = null; function ensureState() { if (!restorationTask) { restorationTask = new Promise(resolve => { let resolved = false; const done = () => { if (!resolved) { resolved = true; resolve(); } }; const storageTimeout = setTimeout(() => { addLog('Storage restoration timed out, continuing with defaults', 'warn'); storageInitialized = true; done(); }, 10000); chrome.storage.session.get([ 'logs', 'history', 'currentRoom', 'lastActionState', 'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks', 'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle', 'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt', 'hcmDesynced' ], (data) => { clearTimeout(storageTimeout); if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount; if (data.currentTabId !== undefined) currentTabId = normalizeTabId(data.currentTabId); if (data.currentTabTitle !== undefined) { currentTabTitle = currentTabId !== null && typeof data.currentTabTitle === 'string' ? data.currentTabTitle : null; } // Merge data from storage with any early-arriving state // New entries (added during boot) must stay at the top (index 0) if (data.logs) logs = [...logs, ...data.logs].slice(0, 200); if (data.history) history = [...history, ...data.history].slice(0, 20); if (data.currentRoom) { currentRoom = data.currentRoom; // Host Control Mode: restore role/mode/capabilities from persisted room. controlMode = currentRoom.controlMode || CONTROL_MODES.EVERYONE; hostPeerId = currentRoom.hostPeerId || null; controllers = Array.isArray(currentRoom.controllers) ? currentRoom.controllers : []; serverCapabilities = Array.isArray(currentRoom.capabilities) ? currentRoom.capabilities : []; } if (data.hcmDesynced !== undefined) hcmDesynced = data.hcmDesynced; // L-2: enforce the desync invariant on restore — a persisted hcmDesynced=true // is stale if our restored role is no longer "gated guest" (e.g. we became // the host, or the room is in 'everyone'). Without this, the first heartbeat // after SW restart would broadcast a bogus Solo flag for up to 15s. hcmEnforceDesyncInvariant(); if (data.lastActionState) lastActionState = data.lastActionState; if (data.eventQueue) eventQueue = [...eventQueue, ...data.eventQueue].slice(0, 50); if (data.isForceSyncInitiator !== undefined && isForceSyncInitiator === false) { isForceSyncInitiator = data.isForceSyncInitiator; } if (data.forceSyncAcks) { const mergedAcks = new Set([...forceSyncAcks, ...data.forceSyncAcks]); forceSyncAcks = mergedAcks; } if (data.reconnectFailed !== undefined) reconnectFailed = data.reconnectFailed; if (data.reconnectStartTime) reconnectStartTime = data.reconnectStartTime; if (data.reconnectAttempts !== undefined) reconnectAttempts = data.reconnectAttempts; if (data.roomIdleSince !== undefined) roomIdleSince = data.roomIdleSince; if (data.lastContentHeartbeatAt !== undefined) lastContentHeartbeatAt = data.lastContentHeartbeatAt; // Recover Force Sync Timeout if (data.forceSyncDeadline) { const remaining = data.forceSyncDeadline - Date.now(); if (remaining > 0 && isForceSyncInitiator) { forceSyncTimeout = setTimeout(() => { if (isForceSyncInitiator) { addLog('Force Sync: Recovered timeout triggered, executing...', 'warn'); executeForceSync(); } }, remaining); } else if (remaining <= 0 && isForceSyncInitiator) { executeForceSync(); } } // Recover Episode Lobby if (data.episodeLobby && !episodeLobby) { episodeLobby = data.episodeLobby; const lobbyRemaining = (episodeLobby.createdAt + EPISODE_LOBBY_TIMEOUT) - Date.now(); if (lobbyRemaining > 0) { episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout'), lobbyRemaining); } else { cancelEpisodeLobby('Timeout (recovered)'); } } if (data.localSeq !== undefined && !isNaN(data.localSeq)) localSeq = data.localSeq; if (data.lastSeqBySender && typeof data.lastSeqBySender === 'object') Object.assign(lastSeqBySender, data.lastSeqBySender); storageInitialized = true; // Process any early logs/history that weren't captured in the spread if (pendingLogs.length > 0) { logs = [...pendingLogs, ...logs].slice(0, 200); chrome.storage.session.set({ logs }); pendingLogs = []; } if (pendingHistory.length > 0) { history = [...pendingHistory, ...history].slice(0, 20); chrome.storage.session.set({ history }); pendingHistory = []; } done(); }); }); } return restorationTask; } // Start restoration immediately ensureState(); let reconnectTimer = null; let reconnectStartTime = null; let reconnectFailed = false; let reconnectAttempts = 0; let currentServerUrl = null; let roomIdleSince = null; let lastContentHeartbeatAt = null; let connectIntent = false; const MAX_RECONNECT_ATTEMPTS = 20; // Backoff tuned so that at most ~8 connection attempts land in any 60s window, // keeping a single client comfortably under the server's per-IP connection // budget (10/min) even before jitter. Cumulative (no jitter): 1, 2.8, 6, 11.9, // 22.4, 34.4, 46.4, 58.4s → 8th attempt at ~58s. const _RECONNECT_BASE_DELAY = 1000; const _RECONNECT_MAX_DELAY = 12000; const _RECONNECT_FACTOR = 1.8; const _RECONNECT_GIVEUP_MS = 300000; // switch to slow mode after 5 min of fast retries const _RECONNECT_SLOW_DELAY = 300000; // slow-mode interval: every 5 min const _RECONNECT_JITTER = 0.2; // ±20% randomization to de-synchronize reconnect herds // Paced queue flush: after a (re)connect we drain the offline event backlog in // small batches instead of one synchronous burst, so we stay well under the // server's per-socket event budget (50 / 10s) and leave headroom for the // heartbeats/pings/commands that also count toward it. 10 per 3s ≈ 33/10s. const FLUSH_BATCH_SIZE = 10; const FLUSH_BATCH_INTERVAL_MS = 3000; // Ping liveness: a single unanswered ping is tolerated (transient network // blip); only MAX_MISSED_PONGS consecutive misses force a reconnect. With a // 15s interval and 5s timeout that means ~20s to detect a genuinely dead link. const PING_INTERVAL_MS = 15000; const PING_TIMEOUT_MS = 5000; const MAX_MISSED_PONGS = 2; const ROOM_IDLE_AUTO_LEAVE_MS = 2 * 60 * 60 * 1000; // Force Sync Coordination let isForceSyncInitiator = false; let forceSyncAcks = new Set(); let forceSyncTimeout = null; // Episode Auto-Sync Lobby let episodeLobby = null; // { expectedTitle, initiatorPeerId, readyPeers: [], createdAt } let episodeLobbyTimeout = null; // --- Storage Utils --- /** * Canonical peer data factory. All peer object construction must go through * here to guarantee a consistent shape with predictable null defaults. * @param {object} raw - Raw data from server event or heartbeat payload. * @returns {object} Normalized peer data object. */ function createPeerData(raw) { return { peerId: raw.peerId || null, username: raw.username || null, tabTitle: raw.tabTitle || null, mediaTitle: raw.mediaTitle || null, playbackState: raw.playbackState || null, currentTime: raw.currentTime != null ? raw.currentTime : null, volume: raw.volume != null ? raw.volume : null, muted: raw.muted != null ? raw.muted : null, desynced: raw.desynced === true, // HCM: peer is watching on their own lastHeartbeat: Date.now() }; } /** * Updates properties of a peer in the room and instantly broadcasts the changes to the popup UI. * Also tracks lastReactiveUpdate to guard against older heartbeats in transit overwriting state. */ function updateLocalPeerState(targetPeerId, updates) { if (!currentRoom || !Array.isArray(currentRoom.peers)) return; 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) { peer[key] = updates[key]; } }); peer.lastReactiveUpdate = Date.now(); // Race condition guard lock if (updates.currentTime !== undefined && updates.currentTime !== null) { peer.lastHeartbeat = Date.now(); // reset time interpolation baseline } if (storageInitialized) chrome.storage.session.set({ currentRoom }); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); } } async function getPeerId() { const data = await chrome.storage.local.get(['peerId']); if (data.peerId) return data.peerId; // 16 hex chars = 64 bits. At a busy relay (25k concurrent peers) the 32-bit // (8-hex) generation would hit ~7% collision probability per snapshot — // and a same-room collision triggers our dedup path, kicking the older // session with a confusing error. 16 hex chars drops the probability to // ~1e-10 even at a million peers, and the server already clamps peerId to // 16 chars (server/index.js JOIN_ROOM sanitizer). Existing persisted 8-char // IDs continue to work — this only affects newly-generated IDs. const newId = self.crypto.randomUUID().replace(/-/g, '').substring(0, 16); await chrome.storage.local.set({ peerId: newId }); return newId; } async function getSettings() { // Local-only by design. Room credentials (roomId/password) and identity // (username) must NEVER come from storage.sync — syncing them across devices // both leaks them and resurrects dead rooms on reinstall (a fresh install // has empty local storage but sync survives in the user's Google account). const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'chatNotifications', 'username', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode']); let username = data.username; if (!username) { username = generateUsername(); await chrome.storage.local.set({ username }); } const legacyTitlePrivacyMode = normalizeTitlePrivacyMode(data.titlePrivacyMode); const mediaTitlePrivacyMode = normalizeTitlePrivacyMode(data.mediaTitlePrivacyMode || legacyTitlePrivacyMode); const chatKey = validateChatSecret(data.chatKey); chatSecretGuard = chatKey; const roomId = normalizeRoomId(data.roomId); if (data.roomId && data.roomId !== roomId) chrome.storage.local.set({ roomId }).catch(() => {}); return { serverUrl: data.serverUrl || '', useCustomServer: data.useCustomServer || false, roomId, password: data.password || '', chatKey, chatEnabled: data.chatEnabled === true, chatNotifications: data.chatNotifications !== false, username, sendTabTitle: normalizeSendTabTitle(data.sendTabTitle, legacyTitlePrivacyMode), mediaTitlePrivacyMode }; } function getSharedTitleFields(settings, mediaTitle = null) { return { tabTitle: sanitizeTabTitle(currentTabTitle, settings?.sendTabTitle), mediaTitle: sanitizeSharedTitle(mediaTitle, settings?.mediaTitlePrivacyMode) }; } function withTitlePrivacy(payload, settings, keys) { return applyTitlePrivacyToPayload(payload, settings?.mediaTitlePrivacyMode, keys); } function emitEpisodeLobbyForCurrentPrivacy() { if (!episodeLobby || episodeLobby.initiatorPeerId !== peerId) return; getSettings().then(settings => { if (!episodeLobby || episodeLobby.initiatorPeerId !== peerId) return; const expectedTitle = sanitizeSharedTitle(episodeLobby.expectedTitle, settings.mediaTitlePrivacyMode); if (expectedTitle) { emit(EVENTS.EPISODE_LOBBY, { peerId, expectedTitle }); } }).catch(err => { addLog('Episode lobby privacy error: ' + err.message, 'error'); }); } // Privacy + correctness: only onboardingComplete and dismissedHints belong in // storage.sync. Everything else is per-device local storage. This actively // removes legacy keys that older versions wrote to sync (and that would // otherwise be redistributed across devices and resurrected on reinstall). const LEGACY_SYNC_KEYS = [ 'serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'chatNotifications', 'chatPosition', 'chatSize', 'chatStartMode', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'titlePrivacyMode', 'sendTabTitle', 'mediaTitlePrivacyMode' ]; function purgeLegacySyncKeys() { chrome.storage.sync.remove(LEGACY_SYNC_KEYS).catch(() => {}); } function addLog(message, type = 'info') { const log = { timestamp: new Date().toISOString(), message, type }; if (!storageInitialized) { pendingLogs.unshift(log); } else { logs.unshift(log); if (logs.length > 200) logs.pop(); chrome.storage.session.set({ logs }); } chrome.runtime.sendMessage({ type: 'LOG_UPDATE', log }).catch(() => {}); } // --- WebSocket Client --- function resolveServerUrl(settings) { return (settings.serverUrl && settings.useCustomServer) ? settings.serverUrl : OFFICIAL_SERVER_URL; } function forceDisconnect() { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } if (episodeLobbyTimeout) { clearTimeout(episodeLobbyTimeout); episodeLobbyTimeout = null; } episodeLobby = null; if (forceSyncTimeout) { clearTimeout(forceSyncTimeout); forceSyncTimeout = null; } stopPing(); if (socket) { socket.onopen = null; socket.onmessage = null; socket.onclose = null; socket.onerror = null; socket.close(); socket = null; } currentServerUrl = null; isConnecting = false; isNamespaceJoined = false; invalidateChatSession(); isForceSyncInitiator = false; expectedAcksCount = 0; roomIdleSince = null; lastContentHeartbeatAt = null; forceSyncAcks.clear(); if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } eventQueue = []; chrome.storage.session.set({ isForceSyncInitiator: false, forceSyncAcks: [], forceSyncDeadline: null, expectedAcksCount: 0, eventQueue: [], episodeLobby: null, roomIdleSince: null, lastContentHeartbeatAt: null }).catch(() => {}); if (currentRoom) { currentRoom.peers = []; if (storageInitialized) chrome.storage.session.set({ currentRoom }); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); } broadcastConnectionStatus('disconnected'); } function persistRoomIdleState() { chrome.storage.session.set({ roomIdleSince, lastContentHeartbeatAt }).catch(() => {}); } function markRoomUseful() { roomIdleSince = null; lastContentHeartbeatAt = Date.now(); persistRoomIdleState(); } function markRoomPotentiallyIdle() { if (!currentRoom) { roomIdleSince = null; lastContentHeartbeatAt = null; persistRoomIdleState(); return; } if (!roomIdleSince) { roomIdleSince = Date.now(); persistRoomIdleState(); } } function invalidateTargetActivations() { targetActivationGeneration++; activeTargetActivation = null; return targetActivationGeneration; } function isCurrentTargetIdentity(tabId, generation) { return normalizeTabId(currentTabId) === normalizeTabId(tabId) && targetActivationGeneration === generation; } function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) { if (expectedTabId !== null && normalizeTabId(currentTabId) !== normalizeTabId(expectedTabId)) { return false; } if (expectedGeneration !== null && targetActivationGeneration !== expectedGeneration) { return false; } completeForceSyncBeforeTargetChange(null); invalidateTargetActivations(); clearPendingTarget().catch(() => {}); if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {}); currentTabId = null; currentTabTitle = null; lastContentHeartbeatAt = null; if (currentRoom) { roomIdleSince = Date.now(); } chrome.storage.session.set({ currentTabId, currentTabTitle, roomIdleSince, lastContentHeartbeatAt }).catch(() => {}); updateBadgeStatus(); return true; } async function leaveRoomAfterIdleGrace(reason) { if (!currentRoom) return; connectIntent = false; reconnectFailed = false; reconnectAttempts = 0; chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }); completeForceSyncBeforeTargetChange(null); emit(EVENTS.LEAVE_ROOM, { peerId }); forceDisconnect(); currentRoom = null; controlMode = CONTROL_MODES.EVERYONE; hostPeerId = null; controllers = []; serverCapabilities = []; hcmDesynced = false; // Notify content.js/popup BEFORE currentTabId is cleared so they can reset // any stale guest-side HCM state (dialog/badge/desync) — H-2. broadcastControlMode(); if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {}); invalidateTargetActivations(); currentTabId = null; currentTabTitle = null; roomIdleSince = null; lastContentHeartbeatAt = null; clearEpisodeLobbyState(); await clearPendingTarget(); await chrome.storage.session.set({ currentRoom: null, currentTabId: null, currentTabTitle: null, roomIdleSince: null, lastContentHeartbeatAt: null, episodeLobby: null, hcmDesynced: false }).catch(() => {}); chatSecretGuard = ''; invalidateChatSession(); await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {}); addLog(reason, 'info'); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); updateBadgeStatus(); } async function connect() { if (isConnecting) return; isConnecting = true; let finalUrl = ''; try { // --- Phase 1: Storage --- let settings; try { if (!peerId) peerId = await getPeerId(); settings = await getSettings(); } catch (e) { throw new Error(`[Storage Error] ${e.message}`); } // --- Phase 2: Connection Guard --- if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) { if (isNamespaceJoined) { isConnecting = false; return; } socket.onopen = null; socket.onmessage = null; socket.onclose = null; socket.onerror = null; socket.close(); } if (!navigator.onLine) { addLog('Browser is offline. Waiting...', 'warn'); broadcastConnectionStatus('offline'); isConnecting = false; if (currentRoom || connectIntent) { scheduleReconnect(); } return; } broadcastConnectionStatus('reconnecting'); const isCustomServer = settings.serverUrl && settings.useCustomServer; finalUrl = isCustomServer ? settings.serverUrl : OFFICIAL_SERVER_URL; // --- Phase 3: URL Validation --- try { if (isCustomServer) { finalUrl = finalUrl.trim(); if (!finalUrl.includes('://')) { finalUrl = 'ws://' + finalUrl; } const urlObj = new URL(finalUrl); const isLocal = urlObj.hostname === 'localhost' || urlObj.hostname === '127.0.0.1'; if (urlObj.protocol !== 'wss:' && !isLocal) { urlObj.protocol = 'wss:'; finalUrl = urlObj.toString(); addLog('Security: Upgraded to wss:// for remote host.', 'warn'); } } } catch (e) { throw new Error(`[URL Error] ${e.message}`); } addLog(`Connecting to ${isCustomServer ? finalUrl : 'Official Server'}... (attempt ${reconnectAttempts + 1})`, 'info'); currentServerUrl = finalUrl; // --- Phase 4: WebSocket Init --- try { const url = new URL(finalUrl); url.pathname = '/socket.io/'; url.searchParams.set('EIO', '4'); url.searchParams.set('transport', 'websocket'); url.searchParams.set('version', chrome.runtime.getManifest().version); url.searchParams.set('token', OFFICIAL_SERVER_TOKEN); socket = new WebSocket(url.toString()); } catch (e) { throw new Error(`[Connection Error] ${e.message}`); } // --- Phase 5: Event Listeners --- socket.onopen = () => { reconnectAttempts = 0; reconnectStartTime = null; reconnectFailed = false; addLog('WebSocket Connection Opened', 'success'); chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }).catch(() => {}); isNamespaceJoined = false; socket.send('40'); }; socket.onmessage = async (event) => { await ensureState(); const msg = event.data; if (msg === '2') { socket.send('3'); return; } if (msg.startsWith('0')) { addLog(`Socket.IO Handshake: ${msg}`, 'info'); } else if (msg.startsWith('40')) { isConnecting = false; isNamespaceJoined = true; broadcastConnectionStatus('connected'); startPing(); addLog('Joined Namespace /', 'success'); const settings = await getSettings(); if (settings.roomId) { const sharedTitles = getSharedTitleFields(settings); emit(EVENTS.JOIN_ROOM, { roomId: settings.roomId, password: settings.password, peerId, username: settings.username, tabTitle: sharedTitles.tabTitle, clientCapabilities: CLIENT_CAPABILITIES, protocolVersion: PROTOCOL_VERSION }); } flushEventQueue(); } else if (msg.startsWith('42')) { try { const payload = JSON.parse(msg.substring(2)); try { await 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'); } } }; socket.onclose = () => { isConnecting = false; isNamespaceJoined = false; invalidateChatSession(); stopPing(); if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } if (!connectIntent && !currentRoom) { isForceSyncInitiator = false; forceSyncAcks.clear(); if (forceSyncTimeout) clearTimeout(forceSyncTimeout); chrome.storage.session.set({ isForceSyncInitiator: false, forceSyncAcks: [], forceSyncDeadline: null }).catch(() => {}); } if (currentRoom && !connectIntent) { currentRoom.peers = []; if (storageInitialized) chrome.storage.session.set({ currentRoom }).catch(() => {}); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); } broadcastConnectionStatus('disconnected'); if (currentRoom || connectIntent) { addLog('Disconnected. Scheduling reconnect...', 'warn'); socket = null; scheduleReconnect(); } else { addLog('Disconnected. No active session — staying disconnected.', 'info'); socket = null; } }; socket.onerror = () => { broadcastConnectionStatus('disconnected'); const logType = reconnectAttempts > 1 ? 'error' : 'warn'; addLog('WebSocket Error: Connection failed', logType); }; } catch (e) { isConnecting = false; const logType = reconnectAttempts > 1 ? 'error' : 'warn'; const errMsg = (e && e.message) ? e.message : String(e || 'Unknown connection error'); addLog(errMsg, logType); broadcastConnectionStatus('disconnected'); if (currentRoom || connectIntent) { scheduleReconnect(); } } } // Invariant: only a gated guest (host-only room AND not the host) can be // "desynced". Any role/mode change that makes us the host, or switches the room // to 'everyone', must clear the persisted flag — otherwise a stale value would // mislabel us as "Solo" to peers and (in content) keep us ignoring host commands // after the reason to is gone. Call after any controlMode/hostPeerId change. function hcmEnforceDesyncInvariant() { if (hcmDesynced && !(controlMode === CONTROL_MODES.HOST_ONLY && !amController())) { hcmDesynced = false; if (storageInitialized) chrome.storage.session.set({ hcmDesynced: false }); } } function broadcastControlMode() { // Notify popup (role badge / host toggle) and the active content tab // (so it can enable/disable the host-only guest gate). const payload = { type: 'CONTROL_MODE', controlMode, hostPeerId, controllers, amHost: amHost(), amController: amController(), hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL), coHostSupported: serverSupports(CAPABILITIES.CO_HOST) }; chrome.runtime.sendMessage(payload).catch(() => {}); if (currentTabId) { const tabId = parseInt(currentTabId); if (!isNaN(tabId)) chrome.tabs.sendMessage(tabId, payload).catch(() => {}); } } function broadcastConnectionStatus(status) { // No room and no intent to connect → this isn't a failure, it's the normal // resting state. Surface a distinct 'idle' status so the UI can say // "ready to connect" instead of a misleading red "Disconnected". if (status === 'disconnected' && !currentRoom && !connectIntent) { status = 'idle'; } chrome.runtime.sendMessage({ type: 'CONNECTION_STATUS', status }).catch(() => {}); if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CONNECTION_STATUS', status }).catch(() => {}); updateBadgeStatus(); } function updateBadgeStatus() { const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined; const isReconnecting = !isConnected && reconnectAttempts > 0; const status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected')); if (status === 'reconnecting') { chrome.action.setBadgeText({ text: '...' }); chrome.action.setBadgeBackgroundColor({ color: '#c96736' }); } else if (status === 'connecting') { chrome.action.setBadgeText({ text: '...' }); chrome.action.setBadgeBackgroundColor({ color: '#de7949' }); } else if (status === 'connected' && currentRoom && currentTabId) { chrome.action.setBadgeText({ text: 'ON' }); chrome.action.setBadgeBackgroundColor({ color: '#56ae6c' }); } else { chrome.action.setBadgeText({ text: '' }); } } function showNotification(senderName, action) { chrome.storage.local.get(['browserNotifications', 'locale'], async (settings) => { if (!settings.browserNotifications) return; const lang = settings.locale || getSystemLanguage(); await loadLocale(lang); let labelKey = ''; if (action === 'play') labelKey = 'NOTIF_PLAY'; else if (action === 'pause') labelKey = 'NOTIF_PAUSE'; else if (action === 'seek') labelKey = 'NOTIF_SEEK'; else if (action === 'force_sync_prepare') labelKey = 'NOTIF_FORCE_PREPARE'; else if (action === 'force_sync_execute') labelKey = 'NOTIF_FORCE_EXECUTE'; const label = labelKey ? getMessage(labelKey) : action; let displayName = senderName || 'A peer'; if (currentRoom && Array.isArray(currentRoom.peers)) { const peer = currentRoom.peers.find(p => (p.peerId || p) === senderName); if (peer && peer.username) displayName = peer.username; } if (displayName === 'You' || displayName === 'YOU') { displayName = getMessage('LABEL_YOU') || 'YOU'; } const message = action === 'joined' ? getMessage('TOAST_PEER_JOINED', { name: displayName }) : action === 'left' ? getMessage('TOAST_PEER_LEFT', { name: displayName }) : getMessage('TOAST_PEER_ACTION', { name: displayName, action: label }) + '.'; chrome.notifications.create(`sync_${Date.now()}`, { type: 'basic', iconUrl: 'icons/icon128.png', title: 'KoalaSync', message: message, priority: 1 }); }); } function showChatNotification(displayName, text) { chrome.storage.local.get(['chatNotifications', 'locale'], async (settings) => { if (settings.chatNotifications === false) return; await loadLocale(settings.locale || getSystemLanguage()); chrome.notifications.create(`chat_${Date.now()}`, { type: 'basic', iconUrl: 'icons/icon128.png', title: getMessage('CHAT_TITLE') || 'KoalaSync', message: `${displayName || getMessage('CHAT_TITLE') || 'Room Chat'}: ${text}`, priority: 1 }); }); } function chatActivityDisplayName(senderId) { if (senderId === peerId) return ''; const peer = currentRoom?.peers?.find(candidate => (typeof candidate === 'object' ? candidate.peerId : candidate) === senderId ); return typeof peer === 'object' ? peer.username || senderId : senderId; } function sendChatActivity(action, senderId, timestamp = Date.now()) { if (!currentTabId || !serverSupportsChat()) return; if (![EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE, 'joined', 'left'].includes(action)) return; chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_EVENT', event: { action, senderId, username: chatActivityDisplayName(senderId), timestamp: Number.isFinite(timestamp) ? timestamp : Date.now() } }).catch(() => {}); } function scheduleReconnect() { if (reconnectTimer) return; if (!reconnectStartTime) reconnectStartTime = Date.now(); const elapsed = Date.now() - reconnectStartTime; reconnectAttempts++; if (!reconnectFailed && (elapsed > _RECONNECT_GIVEUP_MS || reconnectAttempts > MAX_RECONNECT_ATTEMPTS)) { reconnectFailed = true; addLog('Switching to slow reconnect mode (every 5 minutes)', 'warn'); } const baseDelay = reconnectFailed ? _RECONNECT_SLOW_DELAY : Math.min(_RECONNECT_BASE_DELAY * Math.pow(_RECONNECT_FACTOR, reconnectAttempts - 1), _RECONNECT_MAX_DELAY); // Jitter de-synchronizes herds: many clients dropped by the same server // blip won't all reconnect on the same tick and exhaust the connection // budget in lockstep. Applied in both fast and slow mode. const jitterFactor = 1 - _RECONNECT_JITTER + Math.random() * 2 * _RECONNECT_JITTER; const delay = Math.round(baseDelay * jitterFactor); if (reconnectFailed) { addLog(`Slow reconnect in ~5min (attempt ${reconnectAttempts})`, 'info'); } else { addLog(`Reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`, 'warn'); } chrome.storage.session.set({ reconnectFailed, reconnectAttempts, reconnectStartTime }).catch(() => {}); reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delay); } // Slow reconnect logic is now handled in the keepAlive alarm function emit(event, data) { if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) { try { const msg = encodeSocketEvent(event, data, chatSecretGuard); socket.send(msg); } catch (e) { if (e.message === 'Refusing to send chat secret to relay') { addLog(e.message, 'error'); return; } // The socket can close between the readyState check and send() // (race with a server-side disconnect). Re-queue so the event is // retried on the next successful (re)connect instead of being lost. addLog(`Send failed, re-queueing ${event}: ${e.message}`, 'warn'); queueEvent(event, data); } } else { queueEvent(event, data); } } function emitLive(event, data) { if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) return false; try { socket.send(encodeSocketEvent(event, data, chatSecretGuard)); return true; } catch (e) { addLog(e.message === 'Refusing to send chat secret to relay' ? e.message : `Live send failed for ${event}: ${e.message}`, 'error'); return false; } } function queueEvent(event, data) { eventQueue.push({ event, data }); if (eventQueue.length > 50) { eventQueue.shift(); addLog('Event queue cap reached, dropping oldest event', 'warn'); } chrome.storage.session.set({ eventQueue }); } /** * Drain the offline event queue in paced batches. A reconnect after a long * outage can leave up to 50 queued events; dumping them in one tick would * exceed the server's per-socket event budget and get us disconnected right * after rejoining. We send FLUSH_BATCH_SIZE events, then wait * FLUSH_BATCH_INTERVAL_MS before the next batch. Remaining events drain across * subsequent batches; if the connection drops mid-drain, the rest stay queued. */ function flushEventQueue() { if (flushTimer) return; // a drain is already in progress const drainBatch = () => { flushTimer = null; if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) { return; // lost the connection — leave the rest queued for next connect } let sent = 0; while (eventQueue.length > 0 && sent < FLUSH_BATCH_SIZE) { const queuedMsg = eventQueue.shift(); emit(queuedMsg.event, queuedMsg.data); sent++; } chrome.storage.session.set({ eventQueue }).catch(() => {}); if (eventQueue.length > 0) { flushTimer = setTimeout(drainBatch, FLUSH_BATCH_INTERVAL_MS); } }; drainBatch(); } function addToHistory(action, senderId) { const historyEntry = { action, senderId: senderId || 'You', timestamp: new Date().toISOString() }; if (!storageInitialized) { pendingHistory.unshift(historyEntry); } else { history.unshift(historyEntry); if (history.length > 20) history.pop(); chrome.storage.session.set({ history }); } chrome.runtime.sendMessage({ type: 'HISTORY_UPDATE', history }).catch(() => {}); } // --- Ping / Latency --- function sendPing() { const t = Date.now(); pendingPingT = t; emit(EVENTS.PING, { t }); if (pingTimeout) clearTimeout(pingTimeout); pingTimeout = setTimeout(() => { pingTimeout = null; if (pendingPingT !== t) return; // a PONG arrived in time // This ping went unanswered. Tolerate transient blips: only force a // reconnect after MAX_MISSED_PONGS consecutive misses, not the first. pendingPingT = null; missedPongs++; if (missedPongs >= MAX_MISSED_PONGS) { addLog(`${missedPongs} consecutive pings unanswered — force disconnecting to trigger reconnect`, 'warn'); missedPongs = 0; forceDisconnect(); if (currentRoom || connectIntent) { scheduleReconnect(); } } else { addLog(`Ping unanswered (${missedPongs}/${MAX_MISSED_PONGS}) — retrying next interval`, 'warn'); } }, PING_TIMEOUT_MS); } function startPing() { if (pingInterval) clearInterval(pingInterval); if (pingTimeout) { clearTimeout(pingTimeout); pingTimeout = null; } currentPingMs = null; pendingPingT = null; missedPongs = 0; pingInterval = setInterval(sendPing, PING_INTERVAL_MS); sendPing(); } function stopPing() { if (pingInterval) { clearInterval(pingInterval); pingInterval = null; } if (pingTimeout) { clearTimeout(pingTimeout); pingTimeout = null; } currentPingMs = null; pendingPingT = null; missedPongs = 0; } // --- Event Handlers --- async function handleServerEvent(event, data) { if (!data) { addLog(`Ignored server event ${event} due to empty payload`, 'warn'); return; } // Host Control Mode (receiver-side backstop): in host-only mode, ignore // room-moving events from any non-controller. The server already drops these, // so this covers old/buggy/modified clients that slipped through. // Defensive: require a known hostPeerId — if the server ever sends host-only // without a host (state inconsistency), gate-everyone would lock the owner // out of their own room (L-6). if (controlMode === CONTROL_MODES.HOST_ONLY && hostPeerId && HOST_ONLY_GATED_ACTIONS.includes(event) && data.senderId && data.senderId !== hostPeerId && !controllers.includes(data.senderId)) { addLog(`Ignored ${event} from non-controller ${data.senderId} (host-only)`, 'warn'); return; } switch (event) { case EVENTS.ROOM_DATA: if (currentRoom?.roomId !== data.roomId) invalidateChatSession(); currentRoom = data; // Host Control Mode: adopt room role/mode on (re)join. controlMode = data.controlMode || CONTROL_MODES.EVERYONE; hostPeerId = data.hostPeerId || null; controllers = Array.isArray(data.controllers) ? data.controllers : []; serverCapabilities = Array.isArray(data.capabilities) ? data.capabilities : []; if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); hcmEnforceDesyncInvariant(); broadcastControlMode(); markRoomPotentiallyIdle(); if (currentRoom && Array.isArray(currentRoom.peers)) { currentRoom.peers = currentRoom.peers.map(p => typeof p === 'object' ? createPeerData(p) : { peerId: p, username: null, tabTitle: null, mediaTitle: null, playbackState: null, currentTime: null, volume: null, muted: null, lastHeartbeat: Date.now() }); // Clear sequence tracking for peers that are no longer in the room const activePeerIds = new Set(currentRoom.peers.map(p => typeof p === 'object' ? p.peerId : p)); Object.keys(lastSeqBySender).forEach(pId => { if (!activePeerIds.has(pId)) { delete lastSeqBySender[pId]; } }); _persistLastSeq(); } else if (currentRoom) { currentRoom.peers = []; } // Recover server-tracked active Episode Lobby if present if (data && data.activeLobby && !episodeLobby) { episodeLobby = { expectedTitle: data.activeLobby.expectedTitle, initiatorPeerId: data.activeLobby.initiatorPeerId, readyPeers: data.activeLobby.readyPeers, createdAt: Date.now() }; persistEpisodeLobby(); broadcastLobbyUpdate(); addLog(`Recovered active episode lobby from server: "${episodeLobby.expectedTitle}"`, 'info'); // Notify content script to start polling if (currentTabId) { const tabId = parseInt(currentTabId); if (!isNaN(tabId)) { chrome.tabs.sendMessage(tabId, { type: 'EPISODE_LOBBY', expectedTitle: episodeLobby.expectedTitle }).catch(() => {}); } } // Schedule timeout if we don't already have one if (!episodeLobbyTimeout) { episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout'), EPISODE_LOBBY_TIMEOUT); } } if (storageInitialized) chrome.storage.session.set({ currentRoom }); addLog(`Joined Room: ${data?.roomId || 'unknown'}`, 'success'); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: data.peers }).catch(() => {}); // Inform Website Bridge & Popup const joinStatusMsg = { type: 'JOIN_STATUS', success: true, message: 'Joined' }; chrome.runtime.sendMessage(joinStatusMsg).catch(() => {}); chrome.tabs.query({}, (tabs) => { tabs.forEach(tab => { chrome.tabs.sendMessage(tab.id, joinStatusMsg).catch(() => {}); }); }); break; case EVENTS.CONTROL_MODE: // Host Control Mode changed (toggle or host-leave fallback). controlMode = data.controlMode || CONTROL_MODES.EVERYONE; hostPeerId = data.hostPeerId || null; controllers = Array.isArray(data.controllers) ? data.controllers : []; hcmEnforceDesyncInvariant(); if (currentRoom) { currentRoom.controlMode = controlMode; currentRoom.hostPeerId = hostPeerId; currentRoom.controllers = controllers; if (storageInitialized) chrome.storage.session.set({ currentRoom }); } addLog(`Control mode: ${controlMode}${amHost() ? ' (you are owner)' : (amController() ? ' (you are controller)' : '')}`, 'info'); broadcastControlMode(); break; case EVENTS.ROOM_LIST: chrome.runtime.sendMessage({ type: 'ROOM_LIST', rooms: data.rooms }).catch(() => {}); break; case EVENTS.CHAT_MESSAGE: { if (!currentRoom || !serverSupportsChat() || !currentTabId) break; const generation = chatSessionGeneration; const roomId = currentRoom.roomId; const tabId = Number(currentTabId); const received = { ...data }; const isCurrentSession = () => generation === chatSessionGeneration && currentRoom?.roomId === roomId && Number(currentTabId) === tabId; chatReceiveQueue = chatReceiveQueue.catch(() => {}).then(async () => { if (!isCurrentSession()) return; const settings = await getSettings(); if (!settings.chatEnabled || !settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) return; const chatKey = settings.chatKey; try { const text = await decryptChatMessage({ ciphertext: received.ciphertext, roomId, senderId: received.senderId, secret: chatKey }); if (!isCurrentSession() || chatSecretGuard !== chatKey) return; const senderPeer = currentRoom.peers?.find(candidate => (typeof candidate === 'object' ? candidate.peerId : candidate) === received.senderId ); if (Number.isInteger(tabId)) { chrome.tabs.sendMessage(tabId, { type: 'CHAT_MESSAGE', message: { id: received.id, senderId: received.senderId, username: typeof senderPeer === 'object' ? senderPeer.username : null, timestamp: received.timestamp, text } }).catch(() => {}); } if (received.senderId !== peerId) { showChatNotification( typeof senderPeer === 'object' ? senderPeer.username : received.senderId, text ); } } catch (_) { if (isCurrentSession()) addLog('Discarded chat message that failed authentication', 'warn'); } }); await chatReceiveQueue; break; } case EVENTS.ERROR: isConnecting = false; // If we get a server error before successfully joining a room, // clear persisted credentials as well, otherwise service-worker // restart would immediately retry the rejected room. if (!currentRoom && connectIntent) { await clearFailedJoinCredentials(); if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } reconnectAttempts = 0; reconnectFailed = false; } broadcastConnectionStatus('disconnected'); addLog(`Server Error: ${data.message}`, 'error'); chrome.storage.local.get(['browserNotifications', 'locale'], async (settings) => { if (!settings.browserNotifications) return; const lang = settings.locale || getSystemLanguage(); await loadLocale(lang); chrome.notifications.create(`error_${Date.now()}`, { type: 'basic', iconUrl: 'icons/icon128.png', title: getMessage('NOTIF_ERROR_TITLE') || 'KoalaSync Error', message: data.message }); }); // Inform Website Bridge & Popup const errStatusMsg = { type: 'JOIN_STATUS', success: false, message: data.message }; chrome.runtime.sendMessage(errStatusMsg).catch(() => {}); chrome.tabs.query({}, (tabs) => { tabs.forEach(tab => { chrome.tabs.sendMessage(tab.id, errStatusMsg).catch(() => {}); }); }); break; case EVENTS.PLAY: case EVENTS.PAUSE: case EVENTS.SEEK: case EVENTS.FORCE_SYNC_PREPARE: if (data.senderId && typeof data.seq === 'number') { const lastSeq = lastSeqBySender[data.senderId]; if (lastSeq !== undefined && data.seq <= lastSeq) { addLog(`Ignored stale ${event} from ${data.senderId} (seq ${data.seq} <= ${lastSeq})`, 'warn'); break; } lastSeqBySender[data.senderId] = data.seq; _persistLastSeq(); } if (data.senderId) { addToHistory(event, data.senderId); showNotification(data.senderId, event); sendChatActivity(event, data.senderId, data.actionTimestamp); updateLastAction(event, data.senderId); lastActionState.targetTime = data.targetTime !== undefined ? data.targetTime : data.currentTime; if (storageInitialized) chrome.storage.session.set({ lastActionState }); // Remote Reactive Update updateLocalPeerState(data.senderId, { playbackState: event === EVENTS.PLAY ? 'playing' : (event === EVENTS.PAUSE ? 'paused' : undefined), currentTime: data.currentTime !== undefined ? data.currentTime : (data.targetTime !== undefined ? data.targetTime : undefined) }); } routeToContent(event, data); break; case EVENTS.FORCE_SYNC_ACK: if (data.senderId && typeof data.seq === 'number') { const lastSeq = lastSeqBySender[data.senderId]; if (lastSeq !== undefined && data.seq <= lastSeq) break; lastSeqBySender[data.senderId] = data.seq; _persistLastSeq(); } if (isForceSyncInitiator) { forceSyncAcks.add(data.senderId); chrome.storage.session.set({ forceSyncAcks: Array.from(forceSyncAcks) }); addLog(`Received ACK from ${data.senderId} (${forceSyncAcks.size})`, 'info'); // Update UI state for buffering progress if (lastActionState && lastActionState.action === EVENTS.FORCE_SYNC_PREPARE) { if (!Array.isArray(lastActionState.acks)) lastActionState.acks = []; if (!lastActionState.acks.includes(data.senderId)) { lastActionState.acks.push(data.senderId); if (storageInitialized) chrome.storage.session.set({ lastActionState }); chrome.runtime.sendMessage({ type: 'ACTION_UPDATE', state: lastActionState }).catch(() => {}); } // Force Sync ACK Reactive Update updateLocalPeerState(data.senderId, { playbackState: 'paused', // Preparing for force sync always pauses the player currentTime: lastActionState.targetTime }); } // 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(); } } break; case EVENTS.FORCE_SYNC_EXECUTE: if (data?.senderId && typeof data.seq === 'number') { const lastSeq = lastSeqBySender[data.senderId]; if (lastSeq !== undefined && data.seq <= lastSeq) break; lastSeqBySender[data.senderId] = data.seq; _persistLastSeq(); } if (data?.senderId) { addToHistory(event, data.senderId); showNotification(data.senderId, event); sendChatActivity(event, data.senderId, data.actionTimestamp); // (The sender's state is updated below with everyone else) } // Force Sync Execute Remote Reactive Update: // Set all peers to playing and apply a reactive lock to block stale heartbeats if (currentRoom && Array.isArray(currentRoom.peers)) { currentRoom.peers.forEach(peer => { if (peer && typeof peer === 'object') { peer.playbackState = 'playing'; peer.lastReactiveUpdate = Date.now(); } }); if (storageInitialized) chrome.storage.session.set({ currentRoom }); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); } routeToContent(event, data); break; case EVENTS.PING: if (data && typeof data.t === 'number' && Number.isFinite(data.t) && data.sender) { emit(EVENTS.PONG, { t: data.t, target: data.sender }); } break; case EVENTS.EVENT_ACK: 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 = []; if (!lastActionState.acks.includes(data.senderId)) { lastActionState.acks.push(data.senderId); if (storageInitialized) chrome.storage.session.set({ lastActionState }); chrome.runtime.sendMessage({ type: 'ACTION_UPDATE', state: lastActionState }).catch(() => {}); // ACK Reactive Update updateLocalPeerState(data.senderId, { playbackState: lastActionState.action === EVENTS.PLAY ? 'playing' : (lastActionState.action === EVENTS.PAUSE ? 'paused' : undefined), currentTime: (lastActionState.action === EVENTS.SEEK || lastActionState.action === EVENTS.FORCE_SYNC_PREPARE) ? lastActionState.targetTime : undefined }); } } } break; case EVENTS.PEER_STATUS: if (currentRoom) { if (!Array.isArray(currentRoom.peers)) currentRoom.peers = []; if (data.status === 'joined') { if (!currentRoom.peers.find(p => (p.peerId || p) === data.peerId)) { const wasSolo = currentRoom.peers.filter(p => (p.peerId || p) !== peerId).length === 0; delete lastSeqBySender[data.peerId]; _persistLastSeq(); currentRoom.peers.push(createPeerData(data)); if (storageInitialized) chrome.storage.session.set({ currentRoom }); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); sendChatActivity('joined', data.peerId, Date.now()); showNotification(data.username || data.peerId, 'joined'); // We were alone and now we're not — proactively push our // current playback state so the newcomer syncs immediately // instead of waiting up to a full heartbeat interval. if (wasSolo && currentTabId) { chrome.tabs.sendMessage(currentTabId, { type: 'REQUEST_HEARTBEAT' }).catch(() => {}); } if (episodeLobby && episodeLobby.initiatorPeerId === peerId) { emitEpisodeLobbyForCurrentPrivacy(); } } } else if (data.status === 'left') { const departedDisplayName = chatActivityDisplayName(data.peerId); sendChatActivity('left', data.peerId, Date.now()); showNotification(departedDisplayName, 'left'); currentRoom.peers = currentRoom.peers.filter(p => (p.peerId || p) !== data.peerId); if (storageInitialized) chrome.storage.session.set({ currentRoom }); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); if (episodeLobby) { checkEpisodeLobbyPeerDeparture(); } if (isForceSyncInitiator) { forceSyncAcks.delete(data.peerId); chrome.storage.session.set({ forceSyncAcks: Array.from(forceSyncAcks) }); expectedAcksCount = Math.max(1, currentRoom.peers ? currentRoom.peers.length : 1); chrome.storage.session.set({ expectedAcksCount }); if (forceSyncAcks.size >= expectedAcksCount) { executeForceSync(); } } } else { const peer = currentRoom.peers.find(p => (typeof p === 'object' ? p.peerId : p) === data.peerId); if (peer) { if (typeof peer === 'object') { peer.tabTitle = data.tabTitle; peer.username = data.username; peer.mediaTitle = data.mediaTitle !== undefined ? data.mediaTitle : peer.mediaTitle; peer.volume = data.volume !== undefined ? data.volume : peer.volume; peer.muted = data.muted !== undefined ? data.muted : peer.muted; // Only update when present. Our own heartbeats now carry // 'desynced', but other PEER_STATUS variants (server join // broadcast, future/old clients) omit it — and clobbering it // to false there would flicker the host's "Solo" badge. if (data.desynced !== undefined) peer.desynced = data.desynced === true; const timeSinceReactive = peer.lastReactiveUpdate ? (Date.now() - peer.lastReactiveUpdate) : Infinity; const ignoreStatus = timeSinceReactive < 300; if (!ignoreStatus) { peer.playbackState = data.playbackState !== undefined ? data.playbackState : peer.playbackState; peer.currentTime = data.currentTime !== undefined ? data.currentTime : peer.currentTime; if (data.playbackState !== undefined || data.currentTime !== undefined) { peer.lastHeartbeat = Date.now(); } } } else { // Migration: replace string peer with normalized object const idx = currentRoom.peers.indexOf(peer); currentRoom.peers[idx] = createPeerData(data); } if (storageInitialized) chrome.storage.session.set({ currentRoom }); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); if (episodeLobby) { checkEpisodeLobbyCompletion(); } } } } break; case EVENTS.EPISODE_LOBBY: if (data.senderId && data.expectedTitle) { addLog(`Episode lobby from ${data.senderId}: "${data.expectedTitle}"`, 'info'); // If we already have a lobby for this same title, treat as dedup if (episodeLobby && sameEpisode(episodeLobby.expectedTitle, data.expectedTitle)) { break; // Already tracking this lobby } // Cancel any existing lobby before starting a new one if (episodeLobby) clearEpisodeLobbyState(); episodeLobby = { expectedTitle: data.expectedTitle, initiatorPeerId: data.senderId, readyPeers: [data.senderId], // Initiator is already ready createdAt: Date.now() }; persistEpisodeLobby(); broadcastLobbyUpdate(); // Start timeout episodeLobbyTimeout = setTimeout(() => cancelEpisodeLobby('Timeout'), EPISODE_LOBBY_TIMEOUT); // Forward to content script to start polling if (currentTabId) { const tabId = parseInt(currentTabId); if (!isNaN(tabId)) { chrome.tabs.sendMessage(tabId, { type: 'EPISODE_LOBBY', expectedTitle: data.expectedTitle }).catch(() => {}); } } } break; case EVENTS.EPISODE_READY: if (episodeLobby && data.senderId) { if (!episodeLobby.readyPeers.includes(data.senderId)) { episodeLobby.readyPeers.push(data.senderId); persistEpisodeLobby(); broadcastLobbyUpdate(); addLog(`Episode ready from ${data.senderId} (${episodeLobby.readyPeers.length})`, 'info'); checkEpisodeLobbyCompletion(); } } 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; case EVENTS.PONG: if (data && typeof data.t === 'number' && Number.isFinite(data.t)) { if (pendingPingT === data.t) { pendingPingT = null; missedPongs = 0; if (pingTimeout) { clearTimeout(pingTimeout); pingTimeout = null; } const rtt = Date.now() - data.t; currentPingMs = (rtt >= 0 && rtt < 30000) ? rtt : null; chrome.runtime.sendMessage({ type: 'PING_UPDATE', ping: currentPingMs }).catch(() => {}); } } break; default: addLog(`Received unknown event from server: ${event}`, 'warn'); break; } } function executeForceSync() { if (forceSyncTimeout) clearTimeout(forceSyncTimeout); isForceSyncInitiator = false; forceSyncAcks.clear(); expectedAcksCount = 0; chrome.storage.session.set({ isForceSyncInitiator: false, forceSyncAcks: [], forceSyncDeadline: null, expectedAcksCount: 0 }); // Set all peers to playing and apply a reactive lock to block stale heartbeats if (currentRoom && Array.isArray(currentRoom.peers)) { currentRoom.peers.forEach(peer => { if (peer && typeof peer === 'object') { peer.playbackState = 'playing'; peer.lastReactiveUpdate = Date.now(); } }); if (storageInitialized) chrome.storage.session.set({ currentRoom }); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {}); } const executionTimestamp = Date.now(); updateLastAction(EVENTS.FORCE_SYNC_EXECUTE, 'You', executionTimestamp); localSeq++; chrome.storage.session.set({ localSeq }); emit(EVENTS.FORCE_SYNC_EXECUTE, { actionTimestamp: executionTimestamp, seq: localSeq }); routeToContent(EVENTS.FORCE_SYNC_EXECUTE, { actionTimestamp: executionTimestamp, seq: localSeq }); sendChatActivity(EVENTS.FORCE_SYNC_EXECUTE, peerId, executionTimestamp); addLog('Force Sync Executed', 'success'); } function completeForceSyncBeforeTargetChange(nextTabId) { if (!isForceSyncInitiator) return; const selectedTabId = normalizeTabId(currentTabId); const normalizedNextTabId = normalizeTabId(nextTabId); if (selectedTabId !== null && selectedTabId === normalizedNextTabId) return; addLog('Finishing Force Sync before target change', 'info'); executeForceSync(); } // --- Episode Auto-Sync Lobby Functions --- function persistEpisodeLobby() { if (storageInitialized) chrome.storage.session.set({ episodeLobby }); } function broadcastLobbyUpdate() { chrome.runtime.sendMessage({ type: 'LOBBY_UPDATE', lobby: episodeLobby }).catch(() => {}); } function clearEpisodeLobbyState() { if (episodeLobbyTimeout) clearTimeout(episodeLobbyTimeout); episodeLobbyTimeout = null; episodeLobby = null; if (storageInitialized) chrome.storage.session.set({ episodeLobby: null }); broadcastLobbyUpdate(); // Notify content script to stop polling if (currentTabId) { const tabId = parseInt(currentTabId); if (!isNaN(tabId)) { chrome.tabs.sendMessage(tabId, { type: 'EPISODE_LOBBY_CANCEL' }).catch(() => {}); } } } 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'); const reasonKeys = { 'Timeout': 'LOBBY_CANCEL_TIMEOUT', 'Timeout (recovered)': 'LOBBY_CANCEL_TIMEOUT_RECOVERED', 'All other peers left': 'LOBBY_CANCEL_PEERS_LEFT', 'Timeout — not all peers loaded the episode': 'LOBBY_CANCEL_TIMEOUT_PEERS_LOAD', 'Cancelled by user': 'LOBBY_CANCEL_USER' }; // Chrome notification on failure (per Q2: only notify on failure) chrome.storage.local.get(['browserNotifications', 'locale'], async (settings) => { if (!settings.browserNotifications) return; const lang = settings.locale || getSystemLanguage(); await loadLocale(lang); const reasonKey = reasonKeys[reason]; const localizedReason = reasonKey ? getMessage(reasonKey) : reason; const titleText = getMessage('NOTIF_LOBBY_CANCEL_TITLE') || 'KoalaSync — Episode Sync Failed'; const messageText = getMessage('NOTIF_LOBBY_CANCEL_MSG', { reason: localizedReason }) || `Auto-sync cancelled: ${localizedReason}. You may need to manually sync.`; chrome.notifications.create(`episode_${Date.now()}`, { type: 'basic', iconUrl: 'icons/icon128.png', title: titleText, message: messageText, priority: 1 }); }); } function executeEpisodeLobby() { if (!episodeLobby) return; const title = episodeLobby.expectedTitle; clearEpisodeLobbyState(); addLog(`Episode lobby complete: Starting "${title}" via Force Sync`, 'success'); 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); lastActionState.targetTime = 0.0; if (storageInitialized) chrome.storage.session.set({ lastActionState }); chrome.storage.session.set({ isForceSyncInitiator: true, forceSyncAcks: [], forceSyncDeadline: deadline, expectedAcksCount: expectedAcksCount }); const syncPayload = { targetTime: 0.0 }; localSeq++; chrome.storage.session.set({ localSeq }); emit(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, peerId, actionTimestamp: timestamp, seq: localSeq }); routeToContent(EVENTS.FORCE_SYNC_PREPARE, { ...syncPayload, actionTimestamp: timestamp, seq: localSeq }); forceSyncTimeout = setTimeout(() => { if (isForceSyncInitiator) { addLog('Force Sync (Episode): Timeout waiting for ACKs, executing anyway...', 'warn'); executeForceSync(); } }, FORCE_SYNC_TIMEOUT); } function checkEpisodeLobbyCompletion() { if (!episodeLobby || !currentRoom) return; const peers = Array.isArray(currentRoom.peers) ? currentRoom.peers : []; // M-3: desynced peers (watching on their own) sit out the lobby — their content // script ignores EPISODE_LOBBY and never reports ready. Don't let them block // completion: count only peers who actually participate. const participatingCount = peers.filter(p => !(typeof p === 'object' && p.desynced)).length; if (episodeLobby.readyPeers.length >= participatingCount) { executeEpisodeLobby(); } } 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 if (remainingPeerIds.length <= 1) { cancelEpisodeLobby('All other peers left'); return; } // Filter readyPeers to only include peers still in the room episodeLobby.readyPeers = episodeLobby.readyPeers.filter(id => remainingPeerIds.includes(id)); persistEpisodeLobby(); broadcastLobbyUpdate(); // Re-check if all remaining peers are now ready checkEpisodeLobbyCompletion(); } function updateLastAction(action, senderId, timestamp = Date.now()) { lastActionState = { action, senderId, timestamp, acks: [] }; if (storageInitialized) chrome.storage.session.set({ lastActionState }); chrome.runtime.sendMessage({ type: 'ACTION_UPDATE', state: lastActionState }).catch(() => {}); } async function routeToContent(action, payload) { if (!currentTabId) return; const tabId = normalizeTabId(currentTabId); if (tabId === null) return; const targetGeneration = targetActivationGeneration; const actionTimestamp = payload?.actionTimestamp || Date.now(); const commandSenderId = payload?.senderId || null; _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, 0, targetGeneration); } function getTabVideoState(tabId) { return new Promise((resolve) => { chrome.tabs.sendMessage(tabId, { type: 'GET_VIDEO_STATE' }, (res) => { if (chrome.runtime.lastError) { resolve({ error: chrome.runtime.lastError.message }); return; } resolve(res); }); }); } async function getReadyTabVideoState(tabId, expectedGeneration = targetActivationGeneration) { let state = await getTabVideoState(tabId); if (!state || state.error) { const activation = await reactivateCurrentTarget(tabId, { expectedGeneration }); if (activation?.status !== 'ok') { return { error: 'Target tab changed before content script recovery completed' }; } await new Promise(resolve => setTimeout(resolve, 250)); if (!isCurrentTargetIdentity(tabId, activation.generation)) { return { error: 'Target tab changed before video state could be read' }; } state = await getTabVideoState(tabId); if (!isCurrentTargetIdentity(tabId, activation.generation)) { return { error: 'Target tab changed while video state was being read' }; } } return state; } async function simulateRemoteSeek(delta, explicitTargetTime = null) { if (!currentTabId) return { status: 'no_tab' }; const tabId = parseInt(currentTabId); if (isNaN(tabId)) return { status: 'no_tab' }; const state = await getReadyTabVideoState(tabId); if (!state || state.error) return { status: 'error', message: state?.error || 'No video state' }; if (!state.found || !Number.isFinite(state.currentTime)) return { status: 'no_video' }; let targetTime = explicitTargetTime !== null ? explicitTargetTime : Math.max(0, state.currentTime + (delta || 0)); if (Number.isFinite(state.duration) && state.duration > 0) { targetTime = Math.min(targetTime, Math.max(0, state.duration - 0.1)); } const senderId = 'KoalaDev'; const timestamp = Date.now(); const payload = { senderId, actionTimestamp: timestamp, currentTime: targetTime, targetTime }; addToHistory(EVENTS.SEEK, senderId); showNotification(senderId, EVENTS.SEEK); updateLastAction(EVENTS.SEEK, senderId, timestamp); lastActionState.targetTime = targetTime; if (storageInitialized) chrome.storage.session.set({ lastActionState }); updateLocalPeerState(senderId, { currentTime: targetTime }); routeToContent(EVENTS.SEEK, payload); return { status: 'ok', targetTime }; } async function devRemoteToolsAllowed() { const data = await chrome.storage.local.get(['username']); return data.username === 'KoalaDev'; } function shouldUsePageApiSeek(url) { return typeof globalThis.koalaFindPageApiSeekProvider === 'function' && !!globalThis.koalaFindPageApiSeekProvider(url); } function installPageApiSeekBridge() { if (window.__koalaPageApiSeekBridgeInstalled) return; window.__koalaPageApiSeekBridgeInstalled = true; function currentMatch() { return typeof window.koalaFindPageApiSeekProvider === 'function' ? window.koalaFindPageApiSeekProvider(window.location.hostname) : null; } // Disney+ ("hive"/BAM) player: the real media player hangs off the // custom element as `.mediaPlayer`, exposing precise // seek(ms) and timeline.info (playhead/duration in ms). function disneyMediaPlayer() { const el = document.querySelector('disney-web-player'); return el && el.mediaPlayer ? el.mediaPlayer : null; } function seekWithPageApi(time) { const match = currentMatch(); if (!match) return; try { if (match.provider === 'netflix') { const videoPlayer = window.netflix?.appContext?.state?.playerApp?.getAPI?.().videoPlayer; const ids = videoPlayer?.getAllPlayerSessionIds?.(); const sessionId = ids ? ids[0] : null; const player = sessionId ? videoPlayer.getVideoPlayerBySessionId(sessionId) : null; player?.seek(Math.round(time * 1000)); } else if (match.provider === 'disney') { const mp = disneyMediaPlayer(); if (mp && typeof mp.seek === 'function') mp.seek(Math.round(time * 1000)); } } catch (_e) { // Player not ready or private API changed; the next sync tick can retry. } } window.addEventListener('message', (event) => { if (event.source !== window) return; const data = event.data; if (!data || data.__koalaPageApiSeek !== 1 || data.kind !== 'seek' || typeof data.time !== 'number') return; seekWithPageApi(data.time); }); // Disney+'s