diff --git a/extension/background.js b/extension/background.js index 50ada81..89901bd 100644 --- a/extension/background.js +++ b/extension/background.js @@ -7,7 +7,14 @@ 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 { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest, containsOriginPermission } from './host-access.js'; +import { + GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED, + GOOGLE_DRIVE_PLAYER_AMBIGUOUS, + GOOGLE_DRIVE_PLAYER_ORIGIN_PATTERN, + isGoogleDriveUrl, + resolveMediaScriptTarget +} from './media-frame-target.js'; import './page-api-seek-overrides.js'; // --- Uninstall URL Initialization --- @@ -65,6 +72,8 @@ let peerId = null; // initialized via getPeerId() let currentRoom = null; let currentTabId = null; let currentTabTitle = null; // New: for Smart Matching +let currentTargetFrameId = 0; +let currentTargetDocumentId = null; let targetActivationGeneration = 0; let activeTargetActivation = null; let logs = []; @@ -193,12 +202,23 @@ function ensureState() { 'logs', 'history', 'currentRoom', 'lastActionState', 'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks', 'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle', + 'currentTargetFrameId', 'currentTargetDocumentId', '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); + currentTargetFrameId = currentTabId !== null + && Number.isInteger(data.currentTargetFrameId) + && data.currentTargetFrameId >= 0 + ? data.currentTargetFrameId + : 0; + currentTargetDocumentId = currentTabId !== null + && typeof data.currentTargetDocumentId === 'string' + && data.currentTargetDocumentId + ? data.currentTargetDocumentId + : null; if (data.currentTabTitle !== undefined) { currentTabTitle = currentTabId !== null && typeof data.currentTabTitle === 'string' ? data.currentTabTitle @@ -569,6 +589,77 @@ function isCurrentTargetIdentity(tabId, generation) { && targetActivationGeneration === generation; } +function normalizeFrameId(value) { + return Number.isInteger(value) && value >= 0 ? value : 0; +} + +function currentContentTarget() { + return { + frameId: normalizeFrameId(currentTargetFrameId), + documentId: typeof currentTargetDocumentId === 'string' && currentTargetDocumentId + ? currentTargetDocumentId + : null + }; +} + +function sendMessageToFrame(tabId, frameId, message, callback = null, documentId = null) { + const options = typeof documentId === 'string' && documentId + ? { documentId } + : { frameId: normalizeFrameId(frameId) }; + if (typeof callback === 'function') { + return chrome.tabs.sendMessage(tabId, message, options, callback); + } + return chrome.tabs.sendMessage(tabId, message, options); +} + +function sendMessageToCurrentContent(message, callback = null) { + const tabId = normalizeTabId(currentTabId); + if (tabId === null) { + return typeof callback === 'function' ? undefined : Promise.reject(new Error('No target tab selected')); + } + return sendMessageToFrame( + tabId, + currentTargetFrameId, + message, + callback, + currentTargetDocumentId + ); +} + +function sendMessageToContentTab(tabId, message, callback = null) { + if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) { + return sendMessageToCurrentContent(message, callback); + } + if (typeof callback === 'function') { + return chrome.tabs.sendMessage(tabId, message, callback); + } + return chrome.tabs.sendMessage(tabId, message); +} + +function isCurrentContentSender(sender) { + if (!sender?.tab) return false; + const senderTabId = normalizeTabId(sender.tab.id); + const senderFrameId = normalizeFrameId(sender.frameId); + const matchesActive = senderTabId === normalizeTabId(activeTargetActivation?.tabId) + && senderFrameId === normalizeFrameId(activeTargetActivation?.frameId) + && (!activeTargetActivation?.documentId + || sender.documentId === activeTargetActivation.documentId); + if (Number.isInteger(activeTargetActivation?.frameId)) return matchesActive; + return senderTabId === normalizeTabId(currentTabId) + && senderFrameId === normalizeFrameId(currentTargetFrameId) + && (!currentTargetDocumentId || sender.documentId === currentTargetDocumentId); +} + +function sameContentTarget(left, right) { + return normalizeFrameId(left?.frameId) === normalizeFrameId(right?.frameId) + && (!left?.documentId || !right?.documentId || left.documentId === right.documentId); +} + +function clearCurrentContentTarget() { + currentTargetFrameId = 0; + currentTargetDocumentId = null; +} + function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) { if (expectedTabId !== null && normalizeTabId(currentTabId) !== normalizeTabId(expectedTabId)) { return false; @@ -580,9 +671,10 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) completeForceSyncBeforeTargetChange(null); invalidateTargetActivations(); clearPendingTarget().catch(() => {}); - if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {}); + if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_DESTROY' }).catch(() => {}); currentTabId = null; currentTabTitle = null; + clearCurrentContentTarget(); lastContentHeartbeatAt = null; if (currentRoom) { roomIdleSince = Date.now(); @@ -590,6 +682,8 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) chrome.storage.session.set({ currentTabId, currentTabTitle, + currentTargetFrameId, + currentTargetDocumentId, roomIdleSince, lastContentHeartbeatAt }).catch(() => {}); @@ -615,10 +709,11 @@ async function leaveRoomAfterIdleGrace(reason) { // 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(() => {}); + if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_DESTROY' }).catch(() => {}); invalidateTargetActivations(); currentTabId = null; currentTabTitle = null; + clearCurrentContentTarget(); roomIdleSince = null; lastContentHeartbeatAt = null; clearEpisodeLobbyState(); @@ -627,6 +722,8 @@ async function leaveRoomAfterIdleGrace(reason) { currentRoom: null, currentTabId: null, currentTabTitle: null, + currentTargetFrameId: 0, + currentTargetDocumentId: null, roomIdleSince: null, lastContentHeartbeatAt: null, episodeLobby: null, @@ -846,7 +943,7 @@ function broadcastControlMode() { chrome.runtime.sendMessage(payload).catch(() => {}); if (currentTabId) { const tabId = parseInt(currentTabId); - if (!isNaN(tabId)) chrome.tabs.sendMessage(tabId, payload).catch(() => {}); + if (!isNaN(tabId)) sendMessageToContentTab(tabId, payload).catch(() => {}); } } @@ -858,7 +955,7 @@ function broadcastConnectionStatus(status) { status = 'idle'; } chrome.runtime.sendMessage({ type: 'CONNECTION_STATUS', status }).catch(() => {}); - if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CONNECTION_STATUS', status }).catch(() => {}); + if (currentTabId) sendMessageToCurrentContent({ type: 'CONNECTION_STATUS', status }).catch(() => {}); updateBadgeStatus(); } @@ -1121,7 +1218,7 @@ async function handleServerEvent(event, data) { 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(() => {}); + if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); hcmEnforceDesyncInvariant(); broadcastControlMode(); markRoomPotentiallyIdle(); @@ -1156,7 +1253,7 @@ async function handleServerEvent(event, data) { if (currentTabId) { const tabId = parseInt(currentTabId); if (!isNaN(tabId)) { - chrome.tabs.sendMessage(tabId, { + sendMessageToContentTab(tabId, { type: 'EPISODE_LOBBY', expectedTitle: episodeLobby.expectedTitle }).catch(() => {}); @@ -1224,7 +1321,7 @@ async function handleServerEvent(event, data) { (typeof candidate === 'object' ? candidate.peerId : candidate) === received.senderId ); if (Number.isInteger(tabId)) { - chrome.tabs.sendMessage(tabId, { + sendMessageToContentTab(tabId, { type: 'CHAT_MESSAGE', message: { id: received.id, @@ -1408,7 +1505,7 @@ async function handleServerEvent(event, data) { // 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(() => {}); + sendMessageToCurrentContent({ type: 'REQUEST_HEARTBEAT' }).catch(() => {}); } if (episodeLobby && episodeLobby.initiatorPeerId === peerId) { @@ -1498,7 +1595,7 @@ async function handleServerEvent(event, data) { if (currentTabId) { const tabId = parseInt(currentTabId); if (!isNaN(tabId)) { - chrome.tabs.sendMessage(tabId, { + sendMessageToContentTab(tabId, { type: 'EPISODE_LOBBY', expectedTitle: data.expectedTitle }).catch(() => {}); @@ -1610,7 +1707,7 @@ function clearEpisodeLobbyState() { if (currentTabId) { const tabId = parseInt(currentTabId); if (!isNaN(tabId)) { - chrome.tabs.sendMessage(tabId, { type: 'EPISODE_LOBBY_CANCEL' }).catch(() => {}); + sendMessageToContentTab(tabId, { type: 'EPISODE_LOBBY_CANCEL' }).catch(() => {}); } } } @@ -1739,7 +1836,19 @@ async function routeToContent(action, payload) { const tabId = normalizeTabId(currentTabId); if (tabId === null) return; - const targetGeneration = targetActivationGeneration; + let targetGeneration = targetActivationGeneration; + + try { + const tab = await chrome.tabs.get(tabId); + if (isGoogleDriveUrl(tab?.url || '')) { + const activation = await reactivateCurrentTarget(tabId, { expectedGeneration: targetGeneration }); + if (activation?.status !== 'ok') return; + targetGeneration = activation.generation; + } + } catch (error) { + addLog(`Google Drive target refresh failed: ${error.message}`, 'warn'); + return; + } const actionTimestamp = payload?.actionTimestamp || Date.now(); const commandSenderId = payload?.senderId || null; @@ -1749,7 +1858,7 @@ async function routeToContent(action, payload) { function getTabVideoState(tabId) { return new Promise((resolve) => { - chrome.tabs.sendMessage(tabId, { type: 'GET_VIDEO_STATE' }, (res) => { + sendMessageToContentTab(tabId, { type: 'GET_VIDEO_STATE' }, (res) => { if (chrome.runtime.lastError) { resolve({ error: chrome.runtime.lastError.message }); return; @@ -1761,7 +1870,7 @@ function getTabVideoState(tabId) { async function getReadyTabVideoState(tabId, expectedGeneration = targetActivationGeneration) { let state = await getTabVideoState(tabId); - if (!state || state.error) { + if (!state || state.error || state.found === false) { const activation = await reactivateCurrentTarget(tabId, { expectedGeneration }); if (activation?.status !== 'ok') { return { error: 'Target tab changed before content script recovery completed' }; @@ -1925,11 +2034,33 @@ async function injectContentScript(tabId, { requestHostAccess = true } = {}) { let needsPageApiSeek = false; let pageApiSeekReady = false; let access = null; + let scriptTarget = { tabId }; try { access = await inspectTabHostAccess(chrome, tabId); const url = access.url || ''; needsPageApiSeek = shouldUsePageApiSeek(url); - } catch (_e) { + scriptTarget = await resolveMediaScriptTarget(chrome, tabId, url); + if (activeTargetActivation?.tabId === tabId) { + activeTargetActivation.frameId = Array.isArray(scriptTarget.frameIds) + ? normalizeFrameId(scriptTarget.frameIds[0]) + : 0; + activeTargetActivation.documentId = null; + } + } catch (error) { + if (error?.code === GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED) { + const requestAdded = requestHostAccess + ? await addTabHostAccessRequest(chrome, tabId, GOOGLE_DRIVE_PLAYER_ORIGIN_PATTERN) + : false; + const accessError = new Error('KoalaSync needs access to the embedded Google Drive player'); + accessError.code = HOST_ACCESS_REQUIRED_STATUS; + accessError.tabId = tabId; + accessError.host = 'youtube.googleapis.com'; + accessError.originPattern = GOOGLE_DRIVE_PLAYER_ORIGIN_PATTERN; + accessError.requestAdded = requestAdded === true; + accessError.cause = error; + throw accessError; + } + if (error?.code === GOOGLE_DRIVE_PLAYER_AMBIGUOUS) throw error; // Fall through to the generic content script injection. } @@ -1953,18 +2084,34 @@ async function injectContentScript(tabId, { requestHostAccess = true } = {}) { } await chrome.scripting.executeScript({ - target: { tabId }, + target: scriptTarget, files: ['page-api-seek-overrides.js'] }); await chrome.scripting.executeScript({ - target: { tabId }, + target: scriptTarget, func: setPageApiSeekEnabled, args: [pageApiSeekReady] }); - return await chrome.scripting.executeScript({ - target: { tabId }, + const injectionResults = await chrome.scripting.executeScript({ + target: scriptTarget, files: ['chat-format.js', 'chat-overlay.js', 'content.js'] }); + const frameId = Array.isArray(scriptTarget.frameIds) + ? normalizeFrameId(scriptTarget.frameIds[0]) + : 0; + const frameResult = Array.isArray(injectionResults) + ? injectionResults.find(result => normalizeFrameId(result?.frameId) === frameId) + : null; + if (activeTargetActivation?.tabId === tabId) { + activeTargetActivation.frameId = frameId; + activeTargetActivation.documentId = typeof frameResult?.documentId === 'string' + ? frameResult.documentId + : null; + } + return { + frameId, + documentId: typeof frameResult?.documentId === 'string' ? frameResult.documentId : null + }; } catch (error) { // A temporary activeTab grant is intentionally allowed to win: even if // permissions.contains() reports false, a successful injection above is @@ -2151,10 +2298,12 @@ async function activateTargetTab(tabId, tabTitle, { const activationGeneration = ++targetActivationGeneration; activeTargetActivation = { generation: activationGeneration, tabId: selectedTabId }; const previousTabId = normalizeTabId(currentTabId); + const previousContentTarget = currentContentTarget(); + let injectedContentTarget = { frameId: 0, documentId: null }; try { try { - await injectContentScript(selectedTabId, { requestHostAccess }); + injectedContentTarget = await injectContentScript(selectedTabId, { requestHostAccess }); } catch (error) { if (activationGeneration !== targetActivationGeneration) { if (error?.code === HOST_ACCESS_REQUIRED_STATUS @@ -2170,6 +2319,7 @@ async function activateTargetTab(tabId, tabTitle, { } currentTabId = null; currentTabTitle = null; + clearCurrentContentTarget(); lastContentHeartbeatAt = null; if (currentRoom) roomIdleSince = Date.now(); if (previousTabId) { @@ -2179,6 +2329,8 @@ async function activateTargetTab(tabId, tabTitle, { await chrome.storage.session.set({ currentTabId: null, currentTabTitle: null, + currentTargetFrameId: 0, + currentTargetDocumentId: null, roomIdleSince, lastContentHeartbeatAt: null }); @@ -2216,7 +2368,7 @@ async function activateTargetTab(tabId, tabTitle, { return { status: 'superseded' }; } - await applyAudioSettingsToTab(selectedTabId); + await applyAudioSettingsToTab(selectedTabId, injectedContentTarget); if (activationGeneration !== targetActivationGeneration) { if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); return { status: 'superseded' }; @@ -2231,8 +2383,22 @@ async function activateTargetTab(tabId, tabTitle, { if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); return { status: 'superseded' }; } + if (previousTabId === selectedTabId && !sameContentTarget(previousContentTarget, injectedContentTarget)) { + sendMessageToFrame( + previousTabId, + previousContentTarget.frameId, + { type: 'KOALASYNC_DEACTIVATE' }, + null, + previousContentTarget.documentId + ).catch(() => {}); + } + currentTabId = selectedTabId; currentTabTitle = typeof tabTitle === 'string' ? tabTitle : null; + currentTargetFrameId = normalizeFrameId(injectedContentTarget.frameId); + currentTargetDocumentId = typeof injectedContentTarget.documentId === 'string' + ? injectedContentTarget.documentId + : null; lastContentHeartbeatAt = null; if (currentRoom) roomIdleSince = Date.now(); if (previousTabId && previousTabId !== selectedTabId) { @@ -2242,6 +2408,8 @@ async function activateTargetTab(tabId, tabTitle, { await chrome.storage.session.set({ currentTabId, currentTabTitle, + currentTargetFrameId, + currentTargetDocumentId, roomIdleSince, lastContentHeartbeatAt }); @@ -2281,9 +2449,8 @@ async function retryPendingTarget({ expectedRequestId = null, requireGrantedAcce } if (requireGrantedAccess) { - let access; try { - access = await inspectTabHostAccess(chrome, pending.tabId); + await chrome.tabs.get(pending.tabId); } catch { await clearPendingTarget({ expectedRequestId: pending.requestId, @@ -2291,7 +2458,8 @@ async function retryPendingTarget({ expectedRequestId = null, requireGrantedAcce }); return { status: 'invalid_tab' }; } - if (access.granted !== true || access.originPattern !== pending.originPattern) { + const granted = await containsOriginPermission(chrome, pending.originPattern); + if (granted !== true) { return { status: 'permission_not_granted' }; } pending = await readPendingTarget(); @@ -2366,6 +2534,7 @@ if (chrome.tabs?.onRemoved?.addListener) { if (isCurrent) { currentTabId = null; currentTabTitle = null; + clearCurrentContentTarget(); lastContentHeartbeatAt = null; if (currentRoom) roomIdleSince = Date.now(); } @@ -2421,7 +2590,7 @@ if (chrome.tabs?.onRemoved?.addListener) { function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries, targetGeneration) { if (!isCurrentTargetIdentity(tabId, targetGeneration)) return; - chrome.tabs.sendMessage(tabId, { + sendMessageToContentTab(tabId, { type: 'SERVER_COMMAND', action, payload, @@ -2496,7 +2665,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => { function leaveOldRoomIfSwitching(newRoomId) { if (currentRoom && currentRoom.roomId !== newRoomId) { - if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_RESET' }).catch(() => {}); + if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_RESET' }).catch(() => {}); addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info'); forceDisconnect(); currentRoom = null; @@ -2531,17 +2700,36 @@ function leaveOldRoomIfSwitching(newRoomId) { function resetAudioProcessingInTab(tabId) { if (!tabId) return; + if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) { + sendMessageToCurrentContent({ action: 'RESET_AUDIO_PROCESSING' }).catch(() => {}); + return; + } chrome.tabs.sendMessage(tabId, { action: 'RESET_AUDIO_PROCESSING' }).catch(() => {}); } -async function applyAudioSettingsToTab(tabId) { +async function applyAudioSettingsToTab(tabId, contentTarget = null) { if (!tabId) return; // Local-only: audioSettings are never read from storage.sync. const data = await chrome.storage.local.get(['audioSettings']); - chrome.tabs.sendMessage(tabId, { + const message = { action: 'APPLY_AUDIO_SETTINGS', settings: data.audioSettings - }).catch(() => {}); + }; + if (contentTarget) { + sendMessageToFrame( + tabId, + contentTarget.frameId, + message, + null, + contentTarget.documentId + ).catch(() => {}); + return; + } + if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) { + sendMessageToCurrentContent(message).catch(() => {}); + return; + } + chrome.tabs.sendMessage(tabId, message).catch(() => {}); } // --- Extension Message Listeners --- @@ -2557,7 +2745,7 @@ chrome.storage.onChanged.addListener((changes, area) => { if (area !== 'local' || (!changes.roomId && !changes.chatKey && !changes.chatEnabled)) return; if (changes.chatKey) chatSecretGuard = validateChatSecret(changes.chatKey.newValue); invalidateChatSession(); - if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); + if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); }); async function handleAsyncMessage(message, sender, sendResponse) { @@ -2572,7 +2760,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (settings.roomId && currentRoom && currentRoom.roomId === settings.roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) { broadcastConnectionStatus('connected'); - if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); + if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve)); for (const tab of tabs) { chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {}); @@ -2655,8 +2843,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { chatEnabled: settings.chatEnabled }); } else if (message.type === 'GET_CHAT_CONTEXT') { - const senderTabId = sender.tab?.id; - if (!currentRoom || !currentTabId || senderTabId !== Number(currentTabId)) { + if (!currentRoom || !currentTabId || !isCurrentContentSender(sender)) { sendResponse({ supported: false, hasKey: false }); return; } @@ -2692,8 +2879,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { } }); } else if (message.type === 'CHAT_SEND') { - const senderTabId = sender.tab?.id; - if (!currentRoom || !currentTabId || senderTabId !== Number(currentTabId)) { + if (!currentRoom || !currentTabId || !isCurrentContentSender(sender)) { sendResponse({ status: 'invalid_tab' }); return; } @@ -2784,7 +2970,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { // "Solo" to the host (stale-badge split-brain). sendResponse({ controlMode, hostPeerId, controllers, amHost: amHost(), amController: amController(), desynced: hcmDesynced, hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL), coHostSupported: serverSupports(CAPABILITIES.CO_HOST) }); } else if (message.type === 'REQUEST_HOST_SYNC') { - if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) { + if (sender.tab && !isCurrentContentSender(sender)) { sendResponse({ status: 'ignored_unselected_tab', target: null }); return; } @@ -2811,7 +2997,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { } else if (message.type === 'HCM_DESYNC_STATE') { // content.js tells us whether the local user chose to watch on their own. // Only accept from the currently selected tab. - if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) { + if (sender.tab && !isCurrentContentSender(sender)) { sendResponse({ status: 'ignored_unselected_tab' }); return; } @@ -2839,10 +3025,11 @@ async function handleAsyncMessage(message, sender, sendResponse) { // Notify content.js/popup BEFORE currentTabId is cleared so they drop any // stale guest-side HCM state (dialog/badge/desync) — H-2/H-3. broadcastControlMode(); - if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {}); + if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_DESTROY' }).catch(() => {}); invalidateTargetActivations(); currentTabId = null; currentTabTitle = null; + clearCurrentContentTarget(); roomIdleSince = null; lastContentHeartbeatAt = null; @@ -2929,7 +3116,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) { broadcastConnectionStatus('connected'); - if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); + if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve)); if (!isCurrentJoin()) { sendResponse({ status: 'superseded' }); @@ -2988,12 +3175,10 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse({ error: 'No tabId provided' }); return; } - chrome.tabs.sendMessage(tabId, { type: 'GET_VIDEO_STATE' }, (res) => { - if (chrome.runtime.lastError) { - sendResponse({ error: chrome.runtime.lastError.message }); - } else { - sendResponse(res); - } + getReadyTabVideoState(tabId).then(res => { + sendResponse(res); + }).catch(error => { + sendResponse({ error: error.message }); }); } else if (message.type === 'DEV_SIMULATE_REMOTE_SEEK') { if (!(await devRemoteToolsAllowed())) { @@ -3029,11 +3214,11 @@ async function handleAsyncMessage(message, sender, sendResponse) { HOST_ONLY_GATED_ACTIONS.includes(message.action)) { addLog(`Host-only: blocked local ${message.action} (you are a guest)`, 'warn'); if (sender.tab && sender.tab.id) { - chrome.tabs.sendMessage(sender.tab.id, { + sendMessageToFrame(sender.tab.id, sender.frameId, { type: 'HOST_BLOCKED', action: message.action, target: getHostSyncTarget() - }).catch(() => {}); + }, null, sender.documentId).catch(() => {}); } sendResponse({ status: 'blocked_host_only' }); return; @@ -3141,9 +3326,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { }; if (sender.tab) { - const senderTabId = sender.tab.id; - - if (!currentTabId || currentTabId !== senderTabId) { + if (!isCurrentContentSender(sender)) { sendResponse({ status: 'ignored_unselected_tab' }); return; } @@ -3162,7 +3345,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { }); } } else if (message.type === 'FORCE_SYNC_ACK') { - if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) { + if (sender.tab && !isCurrentContentSender(sender)) { sendResponse({ status: 'ignored_unselected_tab' }); return; } @@ -3190,7 +3373,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { } sendResponse({ status: 'ok' }); } else if (message.type === 'CMD_ACK') { - if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) { + if (sender.tab && !isCurrentContentSender(sender)) { sendResponse({ status: 'ignored_unselected_tab' }); return; } @@ -3210,9 +3393,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse({ status: 'ok' }); } else if (message.type === 'HEARTBEAT') { if (sender.tab) { - const senderTabId = sender.tab.id; - - if (!currentTabId || currentTabId !== senderTabId) { + if (!isCurrentContentSender(sender)) { sendResponse({ status: 'ignored_unselected_tab' }); return; } @@ -3287,6 +3468,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { invalidateTargetActivations(); currentTabId = null; currentTabTitle = null; + clearCurrentContentTarget(); lastContentHeartbeatAt = null; if (currentRoom) roomIdleSince = Date.now(); if (previousTabId) { @@ -3297,6 +3479,8 @@ async function handleAsyncMessage(message, sender, sendResponse) { await chrome.storage.session.set({ currentTabId: null, currentTabTitle: null, + currentTargetFrameId: 0, + currentTargetDocumentId: null, roomIdleSince, lastContentHeartbeatAt: null }); @@ -3324,8 +3508,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { } else if (message.type === 'EPISODE_CHANGED') { // Content script detected an episode transition if (sender.tab) { - const senderTabId = sender.tab.id; - if (!currentTabId || currentTabId !== senderTabId) { + if (!isCurrentContentSender(sender)) { sendResponse({ status: 'ignored_unselected_tab' }); return; } @@ -3410,10 +3593,10 @@ async function handleAsyncMessage(message, sender, sendResponse) { // Tell content script to pause the video and start polling // (This is the only place we pause — after confirming the feature is enabled) if (sender.tab && sender.tab.id) { - chrome.tabs.sendMessage(sender.tab.id, { + sendMessageToFrame(sender.tab.id, sender.frameId, { type: 'PAUSE_FOR_LOBBY', expectedTitle: lobbyTitle - }).catch(() => {}); + }, null, sender.documentId).catch(() => {}); } // Broadcast to room @@ -3428,8 +3611,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse({ status: 'lobby_created' }); } else if (message.type === 'EPISODE_READY_LOCAL') { if (sender.tab) { - const senderTabId = sender.tab.id; - if (!currentTabId || currentTabId !== senderTabId) { + if (!isCurrentContentSender(sender)) { sendResponse({ status: 'ignored_unselected_tab' }); return; } @@ -3468,13 +3650,27 @@ async function handleAsyncMessage(message, sender, sendResponse) { }); } if (currentRoom && currentTabId) { - chrome.tabs.sendMessage(currentTabId, { type: 'REQUEST_HEARTBEAT' }).catch(() => {}); + sendMessageToCurrentContent({ type: 'REQUEST_HEARTBEAT' }).catch(() => {}); } sendResponse({ status: 'ok' }); + } else if (message.type === 'DRIVE_FRAME_VISIBILITY') { + if (!isCurrentContentSender(sender)) { + sendResponse({ status: 'ignored_stale_frame' }); + return; + } + if (message.visible !== false) { + sendResponse({ status: 'ok' }); + return; + } + const tabId = normalizeTabId(sender.tab?.id); + const expectedGeneration = targetActivationGeneration; + const activation = tabId === null + ? null + : await reactivateCurrentTarget(tabId, { expectedGeneration }); + sendResponse(activation || { status: 'invalid_tab' }); } else if (message.type === 'CONTENT_BOOT') { if (sender.tab) { - const senderTabId = sender.tab.id; - if (!currentTabId || currentTabId !== senderTabId) { + if (!isCurrentContentSender(sender)) { sendResponse({ status: 'ignored_unselected_tab' }); return; } @@ -3501,7 +3697,8 @@ async function handleAsyncMessage(message, sender, sendResponse) { initTabManager({ getCurrentTabId: () => currentTabId, reactivateCurrentTarget, - ensureState + ensureState, + sendToCurrentContent: sendMessageToCurrentContent }); // Initial Connect — only if user has an active room configuration diff --git a/extension/content.js b/extension/content.js index aa05517..5451068 100644 --- a/extension/content.js +++ b/extension/content.js @@ -6,13 +6,15 @@ (function() { // Injection Guard: Check if already injected AND context is valid try { - if (window.koalaSyncInjected && chrome.runtime.id) { - return; - } + if (window.koalaSyncInjected && chrome.runtime.id) { + window.koalaSyncTargetActive = true; + return; + } } catch (_e) { // Context invalidated, proceed with re-injection } - window.koalaSyncInjected = true; + window.koalaSyncInjected = true; + window.koalaSyncTargetActive = true; // --- SHARED_EVENTS_INJECT_START --- // This block is automatically updated by /scripts/build-extension.cjs @@ -86,9 +88,34 @@ return Array.isArray(urls) && urls.some(url => hostMatchesUrl(host, url)); } - function isDisneyPlusHost() { - return matchesPlayerUrls(['disneyplus.com']); - } + function isDisneyPlusHost() { + return matchesPlayerUrls(['disneyplus.com']); + } + + function isGoogleDrivePlayerFrame() { + if (window.location.hostname.toLowerCase() !== 'youtube.googleapis.com') return false; + try { + const url = new URL(window.location.href); + const origin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin'); + return origin === 'https://drive.google.com'; + } catch { + return false; + } + } + + function isContentTargetActive() { + return window.koalaSyncTargetActive === true; + } + + if (isGoogleDrivePlayerFrame()) { + let lastDriveFrameVisible = window.innerWidth > 0 && window.innerHeight > 0; + window.addEventListener('resize', () => { + const visible = window.innerWidth > 0 && window.innerHeight > 0; + if (visible === lastDriveFrameVisible || !isContentTargetActive()) return; + lastDriveFrameVisible = visible; + chrome.runtime.sendMessage({ type: 'DRIVE_FRAME_VISIBILITY', visible }).catch(() => {}); + }, { passive: true }); + } function getDisneyPlusTimeline() { if (!isDisneyPlusHost()) return null; @@ -851,11 +878,17 @@ function getPlayerActionFixes() { return [ - { - name: 'youtube-player-buttons', - urls: ['youtube.com'], - playPauseButtonSelector: '.ytp-play-button' - }, + { + name: 'youtube-player-buttons', + urls: ['youtube.com'], + playPauseButtonSelector: '.ytp-play-button' + }, + { + name: 'google-drive-player-buttons', + urls: ['youtube.googleapis.com'], + matches() { return isGoogleDrivePlayerFrame(); }, + playPauseButtonSelector: '.ytp-play-button' + }, { name: 'twitch-player-buttons', urls: ['twitch.tv'], @@ -864,9 +897,11 @@ ]; } - function getActivePlayerActionFix() { - return getPlayerActionFixes().find(fix => matchesPlayerUrls(fix.urls)) || null; - } + function getActivePlayerActionFix() { + return getPlayerActionFixes().find(fix => + typeof fix.matches === 'function' ? fix.matches() : matchesPlayerUrls(fix.urls) + ) || null; + } function tryPlayerActionFix(fix, action, video, data) { if (!fix) return false; @@ -951,8 +986,14 @@ } // Listen for commands from background.js - chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (!message) return; + if (message.type === 'KOALASYNC_DEACTIVATE') { + window.koalaSyncTargetActive = false; + sendResponse({ status: 'ok' }); + return true; + } + if (!isContentTargetActive()) return; if (message.action === 'get_current_time') { const video = findVideo(); sendResponse({ currentTime: video ? getSyncCurrentTime(video) : null }); @@ -1136,9 +1177,10 @@ if (message.type === 'GET_VIDEO_STATE') { const video = findVideo(); - const platform = (() => { - const h = window.location.hostname.toLowerCase(); - if (h === 'youtube.com' || h.endsWith('.youtube.com')) return 'YouTube'; + const platform = (() => { + const h = window.location.hostname.toLowerCase(); + if (isGoogleDrivePlayerFrame()) return 'Google Drive'; + if (h === 'youtube.com' || h.endsWith('.youtube.com')) return 'YouTube'; if (h === 'twitch.tv' || h.endsWith('.twitch.tv')) return 'Twitch'; if (h === 'netflix.com' || h.endsWith('.netflix.com')) return 'Netflix'; if (h === 'primevideo.com' || h.endsWith('.primevideo.com') || /(^|\.)amazon\.(com\.[a-z]{2}|co\.[a-z]{2}|[a-z]{2,})$/.test(h)) return 'Prime Video'; @@ -1271,8 +1313,9 @@ // Build the relay payload from the *current* media state and send it to // background.js. Re-reads the video each call so deferred (coalesced) emits // carry an up-to-date position. Safe with no/invalid video — it no-ops. - function sendContentEvent(action) { - const video = findVideo(); + function sendContentEvent(action) { + if (!isContentTargetActive()) return; + const video = findVideo(); if (!video) return; const current = getSyncCurrentTime(video); @@ -1308,8 +1351,9 @@ sendContentEvent(finalAction); } - function reportEvent(action) { - if (seekDebounceTimer && (action === EVENTS.PLAY || action === EVENTS.PAUSE)) { + function reportEvent(action) { + if (!isContentTargetActive()) return; + if (seekDebounceTimer && (action === EVENTS.PLAY || action === EVENTS.PAUSE)) { clearTimeout(seekDebounceTimer); seekDebounceTimer = null; const v = findVideo(); @@ -1552,8 +1596,9 @@ let proactiveHeartbeatTimeout = null; let heartbeatErrorCount = 0; - function sendHeartbeat() { - const video = findVideo(); + function sendHeartbeat() { + if (!isContentTargetActive()) return; + const video = findVideo(); if (!video) return; const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null; diff --git a/extension/host-access.js b/extension/host-access.js index 4f66ed3..ec64363 100644 --- a/extension/host-access.js +++ b/extension/host-access.js @@ -150,6 +150,18 @@ export function requestOriginPermission(chromeApi, originPattern) { ).catch(() => false); } +export function containsOriginPermission(chromeApi, originPattern) { + if (typeof originPattern !== 'string' || !originPattern) { + return Promise.resolve(null); + } + return callBooleanPermissionMethod( + chromeApi, + 'contains', + { origins: [originPattern] }, + { timeoutMs: 1000 } + ).catch(() => null); +} + export function isHostAccessError(error) { const message = typeof error?.message === 'string' ? error.message : String(error || ''); return /host permission|permission to access (?:this|the|respective) host|cannot access contents of/i.test(message); diff --git a/extension/media-frame-target.js b/extension/media-frame-target.js new file mode 100644 index 0000000..1bd5f63 --- /dev/null +++ b/extension/media-frame-target.js @@ -0,0 +1,262 @@ +const GOOGLE_DRIVE_HOST = 'drive.google.com'; +const GOOGLE_DRIVE_PLAYER_HOST = 'youtube.googleapis.com'; +export const GOOGLE_DRIVE_PLAYER_ORIGIN_PATTERN = 'https://youtube.googleapis.com/*'; +export const GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED = 'google_drive_player_access_required'; +export const GOOGLE_DRIVE_PLAYER_AMBIGUOUS = 'google_drive_player_ambiguous'; + +export function isGoogleDriveUrl(value) { + try { + return new URL(value).hostname.toLowerCase() === GOOGLE_DRIVE_HOST; + } catch { + return false; + } +} + +export function inspectMediaFrame() { + const videos = Array.from(document.querySelectorAll('video')); + const drivePlayerIframes = Array.from(document.querySelectorAll('iframe')).filter((frame) => { + try { + const url = new URL(frame.src); + if (url.hostname.toLowerCase() !== 'youtube.googleapis.com') return false; + const origin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin'); + return origin === 'https://drive.google.com'; + } catch { + return false; + } + }); + let bestVideoScore = 0; + + for (const video of videos) { + const width = video.videoWidth || video.offsetWidth || 0; + const height = video.videoHeight || video.offsetHeight || 0; + const duration = Number.isFinite(video.duration) ? video.duration : 0; + bestVideoScore = Math.max(bestVideoScore, (width * height) + (duration * 100)); + } + + return { + href: window.location.href, + videoCount: videos.length, + videoScore: bestVideoScore, + frameArea: Math.max(0, window.innerWidth) * Math.max(0, window.innerHeight), + drivePlayerIframeCount: drivePlayerIframes.length, + parentFrameVisible: window.__koalaParentFrameVisibility?.visible ?? null, + parentFrameArea: window.__koalaParentFrameVisibility?.area ?? null + }; +} + +export function installParentFrameVisibilityProbe(token) { + const handler = (event) => { + if (event.source !== window.parent + || event.data?.type !== 'KOALASYNC_FRAME_VISIBILITY' + || event.data?.token !== token) { + return; + } + window.__koalaParentFrameVisibility = { + visible: event.data.visible === true, + area: Number.isFinite(event.data.area) ? event.data.area : 0 + }; + window.removeEventListener('message', handler); + }; + window.addEventListener('message', handler); +} + +export async function dispatchParentFrameVisibilityProbe(token) { + let count = 0; + for (const frame of document.querySelectorAll('iframe')) { + try { + const url = new URL(frame.src); + const origin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin'); + if (url.hostname.toLowerCase() !== 'youtube.googleapis.com' + || origin !== 'https://drive.google.com') { + continue; + } + const rect = frame.getBoundingClientRect(); + const style = window.getComputedStyle(frame); + const area = Math.max(0, rect.width) * Math.max(0, rect.height); + const intersectsViewport = rect.bottom > 0 + && rect.right > 0 + && rect.top < window.innerHeight + && rect.left < window.innerWidth; + const browserReportsVisible = typeof frame.checkVisibility === 'function' + ? frame.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }) + : true; + const visible = area > 0 + && intersectsViewport + && browserReportsVisible + && style.display !== 'none' + && style.visibility !== 'hidden' + && Number(style.opacity) !== 0; + frame.contentWindow?.postMessage({ + type: 'KOALASYNC_FRAME_VISIBILITY', + token, + visible, + area + }, 'https://youtube.googleapis.com'); + count++; + } catch { + // Ignore unrelated or not-yet-initialized frames. + } + } + await new Promise(resolve => setTimeout(resolve, 100)); + return count; +} + +function isGoogleDrivePlayerFrameUrl(value) { + try { + const url = new URL(value); + if (url.hostname.toLowerCase() !== GOOGLE_DRIVE_PLAYER_HOST || url.pathname !== '/embed/') { + return false; + } + const origin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin'); + return origin === 'https://drive.google.com'; + } catch { + return false; + } +} + +function createGoogleDrivePlayerAccessError() { + const error = new Error('Google Drive player frame access is required'); + error.code = GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED; + error.originPattern = GOOGLE_DRIVE_PLAYER_ORIGIN_PATTERN; + return error; +} + +function createGoogleDrivePlayerAmbiguousError() { + const error = new Error('Google Drive player frame could not be identified safely'); + error.code = GOOGLE_DRIVE_PLAYER_AMBIGUOUS; + return error; +} + +export function selectGoogleDrivePlayerFrame(injectionResults) { + let candidates = (Array.isArray(injectionResults) ? injectionResults : []) + .filter(entry => Number.isInteger(entry?.frameId) && entry?.result) + .filter(entry => isGoogleDrivePlayerFrameUrl(entry.result.href)); + + if (candidates.length > 1) { + const confirmedVisible = candidates.filter(entry => entry.result.parentFrameVisible === true); + if (confirmedVisible.length > 0) { + candidates = confirmedVisible; + } else { + const notConfirmedHidden = candidates.filter(entry => + entry.result.parentFrameVisible !== false + ); + if (notConfirmedHidden.length !== 1) return null; + candidates = notConfirmedHidden; + } + } + + candidates.sort((left, right) => { + const visibilityRank = result => result.parentFrameVisible === true + ? 2 + : result.parentFrameVisible === false + ? 0 + : 1; + const leftVisible = visibilityRank(left.result); + const rightVisible = visibilityRank(right.result); + if (leftVisible !== rightVisible) return rightVisible - leftVisible; + const leftParentArea = Number.isFinite(left.result.parentFrameArea) + ? left.result.parentFrameArea + : left.result.frameArea; + const rightParentArea = Number.isFinite(right.result.parentFrameArea) + ? right.result.parentFrameArea + : right.result.frameArea; + if (leftParentArea !== rightParentArea) return rightParentArea - leftParentArea; + const leftHasVideo = left.result.videoCount > 0 ? 1 : 0; + const rightHasVideo = right.result.videoCount > 0 ? 1 : 0; + if (leftHasVideo !== rightHasVideo) return rightHasVideo - leftHasVideo; + if (left.result.videoScore !== right.result.videoScore) { + return right.result.videoScore - left.result.videoScore; + } + return right.result.frameArea - left.result.frameArea; + }); + + return candidates[0] || null; +} + +export async function resolveMediaScriptTarget(chromeApi, tabId, tabUrl, { + attempts = 8, + retryDelayMs = 200 +} = {}) { + const topFrameTarget = { tabId }; + if (!isGoogleDriveUrl(tabUrl)) return topFrameTarget; + + let fallbackFrame = null; + let drivePlayerIframeDetected = false; + let ambiguousPlayerFramesDetected = false; + for (let attempt = 0; attempt < attempts; attempt++) { + let results; + try { + results = await chromeApi.scripting.executeScript({ + target: { tabId, allFrames: true }, + func: inspectMediaFrame + }); + } catch { + let topFrameResult = null; + try { + const topResults = await chromeApi.scripting.executeScript({ + target: { tabId }, + func: inspectMediaFrame + }); + topFrameResult = Array.isArray(topResults) ? topResults[0]?.result : null; + } catch { + // The generic injection path below remains the final source of truth. + } + if (topFrameResult?.drivePlayerIframeCount > 0) { + throw createGoogleDrivePlayerAccessError(); + } + return topFrameTarget; + } + + const topFrame = results.find(entry => entry?.frameId === 0); + if (topFrame?.result?.drivePlayerIframeCount > 0) { + drivePlayerIframeDetected = true; + } + const playerFrameCount = results.filter(entry => + isGoogleDrivePlayerFrameUrl(entry?.result?.href) + ).length; + if (playerFrameCount > 1) { + const token = `${tabId}:${attempt}:${Date.now()}:${Math.random()}`; + try { + await chromeApi.scripting.executeScript({ + target: { tabId, allFrames: true }, + func: installParentFrameVisibilityProbe, + args: [token] + }); + await chromeApi.scripting.executeScript({ + target: { tabId }, + func: dispatchParentFrameVisibilityProbe, + args: [token] + }); + results = await chromeApi.scripting.executeScript({ + target: { tabId, allFrames: true }, + func: inspectMediaFrame + }); + } catch { + // Ambiguous frames are retried below; never guess a stale player. + } + } + const selectedFrame = selectGoogleDrivePlayerFrame(results); + if (!selectedFrame && playerFrameCount > 1) { + ambiguousPlayerFramesDetected = true; + } + if (selectedFrame?.result?.videoCount > 0) { + return { tabId, frameIds: [selectedFrame.frameId] }; + } + if (selectedFrame) fallbackFrame = selectedFrame; + + if (attempt < attempts - 1) { + await new Promise(resolve => setTimeout(resolve, retryDelayMs)); + } + } + + if (!fallbackFrame && ambiguousPlayerFramesDetected) { + throw createGoogleDrivePlayerAmbiguousError(); + } + if (!fallbackFrame && drivePlayerIframeDetected) { + throw createGoogleDrivePlayerAccessError(); + } + + return fallbackFrame + ? { tabId, frameIds: [fallbackFrame.frameId] } + : topFrameTarget; +} diff --git a/extension/media-frame-target.test.mjs b/extension/media-frame-target.test.mjs new file mode 100644 index 0000000..f620328 --- /dev/null +++ b/extension/media-frame-target.test.mjs @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED, + GOOGLE_DRIVE_PLAYER_AMBIGUOUS, + isGoogleDriveUrl, + resolveMediaScriptTarget, + selectGoogleDrivePlayerFrame +} from './media-frame-target.js'; + +const drivePlayerUrl = 'https://youtube.googleapis.com/embed/?enablejsapi=1&origin=https%3A%2F%2Fdrive.google.com'; + +describe('Google Drive media-frame targeting', () => { + it('recognizes Drive tabs without treating other Google pages as Drive', () => { + expect(isGoogleDriveUrl('https://drive.google.com/drive/u/1/search?q=mp4')).toBe(true); + expect(isGoogleDriveUrl('https://docs.google.com/document/d/1/edit')).toBe(false); + }); + + it('selects the visible frame containing the real video', () => { + const selected = selectGoogleDrivePlayerFrame([ + { frameId: 0, result: { href: 'https://drive.google.com/drive/u/1/search?q=mp4', videoCount: 0, videoScore: 0, frameArea: 900000 } }, + { frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1900000, frameArea: 900000, parentFrameVisible: true } }, + { frameId: 5, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 0, parentFrameVisible: false } } + ]); + + expect(selected.frameId).toBe(4); + }); + + it('never lets a hidden loaded player outrank the visible Drive player', () => { + const selected = selectGoogleDrivePlayerFrame([ + { frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000, parentFrameVisible: true, parentFrameArea: 900000 } }, + { frameId: 5, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1281600, frameArea: 900000, parentFrameVisible: false, parentFrameArea: 900000 } } + ]); + + expect(selected.frameId).toBe(4); + }); + + it('refuses to guess when parent visibility is unavailable', () => { + const selected = selectGoogleDrivePlayerFrame([ + { frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000 } }, + { frameId: 5, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1281600, frameArea: 900000 } } + ]); + + expect(selected).toBeNull(); + }); + + it('ignores unrelated video frames inside a Drive tab', () => { + const selected = selectGoogleDrivePlayerFrame([ + { frameId: 3, result: { href: 'https://example.com/embed', videoCount: 1, videoScore: 3000000, frameArea: 1200000 } }, + { frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000 } } + ]); + + expect(selected.frameId).toBe(4); + }); + + it('falls back to the visible Drive player frame while its video is loading', () => { + const selected = selectGoogleDrivePlayerFrame([ + { frameId: 4, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 900000, parentFrameVisible: true } }, + { frameId: 5, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 0, parentFrameVisible: false } } + ]); + + expect(selected.frameId).toBe(4); + }); + + it('reports ambiguity instead of selecting a stale frame after retries', async () => { + const ambiguousResults = [ + { frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000 } }, + { frameId: 5, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1281600, frameArea: 900000 } } + ]; + const executeScript = vi.fn().mockResolvedValue(ambiguousResults); + + await expect(resolveMediaScriptTarget( + { scripting: { executeScript } }, + 42, + 'https://drive.google.com/drive/u/1/search?q=mp4', + { attempts: 1, retryDelayMs: 0 } + )).rejects.toMatchObject({ code: GOOGLE_DRIVE_PLAYER_AMBIGUOUS }); + }); + + it('probes all frames and returns only the frame with the Drive video', async () => { + const executeScript = vi.fn() + .mockResolvedValueOnce([ + { frameId: 0, result: { href: 'https://drive.google.com/drive/u/1/search?q=mp4', videoCount: 0, videoScore: 0, frameArea: 900000 } }, + { frameId: 4, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 900000 } } + ]) + .mockResolvedValueOnce([ + { frameId: 0, result: { href: 'https://drive.google.com/drive/u/1/search?q=mp4', videoCount: 0, videoScore: 0, frameArea: 900000 } }, + { frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1900000, frameArea: 900000 } } + ]); + + await expect(resolveMediaScriptTarget( + { scripting: { executeScript } }, + 42, + 'https://drive.google.com/drive/u/1/search?q=mp4', + { retryDelayMs: 0 } + )).resolves.toEqual({ tabId: 42, frameIds: [4] }); + + expect(executeScript).toHaveBeenCalledWith({ + target: { tabId: 42, allFrames: true }, + func: expect.any(Function) + }); + }); + + it('reports missing access when Drive exposes a player iframe but the frame cannot be inspected', async () => { + const executeScript = vi.fn().mockResolvedValue([ + { + frameId: 0, + result: { + href: 'https://drive.google.com/drive/u/1/search?q=mp4', + videoCount: 0, + videoScore: 0, + frameArea: 900000, + drivePlayerIframeCount: 1 + } + } + ]); + + await expect(resolveMediaScriptTarget( + { scripting: { executeScript } }, + 42, + 'https://drive.google.com/drive/u/1/search?q=mp4', + { attempts: 1, retryDelayMs: 0 } + )).rejects.toMatchObject({ code: GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED }); + }); + + it('reports missing access when the all-frame probe itself is rejected', async () => { + const executeScript = vi.fn() + .mockRejectedValueOnce(new Error('Cannot access contents of the frame')) + .mockResolvedValueOnce([ + { + frameId: 0, + result: { + href: 'https://drive.google.com/drive/u/1/search?q=mp4', + videoCount: 0, + videoScore: 0, + frameArea: 900000, + drivePlayerIframeCount: 1 + } + } + ]); + + await expect(resolveMediaScriptTarget( + { scripting: { executeScript } }, + 42, + 'https://drive.google.com/drive/u/1/search?q=mp4', + { attempts: 1, retryDelayMs: 0 } + )).rejects.toMatchObject({ code: GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED }); + }); +}); diff --git a/extension/modules/tab-manager.js b/extension/modules/tab-manager.js index 18cde2f..e6d7e93 100644 --- a/extension/modules/tab-manager.js +++ b/extension/modules/tab-manager.js @@ -1,7 +1,8 @@ export function initTabManager({ getCurrentTabId, reactivateCurrentTarget, - ensureState + ensureState, + sendToCurrentContent }) { chrome.storage.onChanged.addListener(async (changes, area) => { if (area !== 'local' || !changes.audioSettings) return; @@ -9,7 +10,7 @@ export function initTabManager({ const tabId = getCurrentTabId(); if (!tabId) return; - chrome.tabs.sendMessage(tabId, { + sendToCurrentContent({ action: 'APPLY_AUDIO_SETTINGS', settings: changes.audioSettings.newValue }).catch(() => {}); diff --git a/extension/popup.html b/extension/popup.html index 0ed6cc2..499f380 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -1329,7 +1329,7 @@ } @keyframes floatHint { 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-4px); } + 50% { transform: translateY(-2px); } } @@ -1452,7 +1452,7 @@ -