fix(extension): support Google Drive video frames

This commit is contained in:
Timo
2026-07-26 03:52:05 +02:00
parent 90a58eb507
commit 213d2867fc
13 changed files with 852 additions and 112 deletions
+262 -65
View File
@@ -7,7 +7,14 @@ import { initTabManager } from './modules/tab-manager.js';
import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.js'; import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.js';
import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js'; import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js';
import { createChatSendLimiter, createLatestTaskQueue, normalizeRoomId } from './chat-session.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'; import './page-api-seek-overrides.js';
// --- Uninstall URL Initialization --- // --- Uninstall URL Initialization ---
@@ -65,6 +72,8 @@ let peerId = null; // initialized via getPeerId()
let currentRoom = null; let currentRoom = null;
let currentTabId = null; let currentTabId = null;
let currentTabTitle = null; // New: for Smart Matching let currentTabTitle = null; // New: for Smart Matching
let currentTargetFrameId = 0;
let currentTargetDocumentId = null;
let targetActivationGeneration = 0; let targetActivationGeneration = 0;
let activeTargetActivation = null; let activeTargetActivation = null;
let logs = []; let logs = [];
@@ -193,12 +202,23 @@ function ensureState() {
'logs', 'history', 'currentRoom', 'lastActionState', 'logs', 'history', 'currentRoom', 'lastActionState',
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks', 'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle', 'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle',
'currentTargetFrameId', 'currentTargetDocumentId',
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt', 'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt',
'hcmDesynced' 'hcmDesynced'
], (data) => { ], (data) => {
clearTimeout(storageTimeout); clearTimeout(storageTimeout);
if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount; if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount;
if (data.currentTabId !== undefined) currentTabId = normalizeTabId(data.currentTabId); 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) { if (data.currentTabTitle !== undefined) {
currentTabTitle = currentTabId !== null && typeof data.currentTabTitle === 'string' currentTabTitle = currentTabId !== null && typeof data.currentTabTitle === 'string'
? data.currentTabTitle ? data.currentTabTitle
@@ -569,6 +589,77 @@ function isCurrentTargetIdentity(tabId, generation) {
&& targetActivationGeneration === 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) { function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) {
if (expectedTabId !== null && normalizeTabId(currentTabId) !== normalizeTabId(expectedTabId)) { if (expectedTabId !== null && normalizeTabId(currentTabId) !== normalizeTabId(expectedTabId)) {
return false; return false;
@@ -580,9 +671,10 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null)
completeForceSyncBeforeTargetChange(null); completeForceSyncBeforeTargetChange(null);
invalidateTargetActivations(); invalidateTargetActivations();
clearPendingTarget().catch(() => {}); clearPendingTarget().catch(() => {});
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {}); if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_DESTROY' }).catch(() => {});
currentTabId = null; currentTabId = null;
currentTabTitle = null; currentTabTitle = null;
clearCurrentContentTarget();
lastContentHeartbeatAt = null; lastContentHeartbeatAt = null;
if (currentRoom) { if (currentRoom) {
roomIdleSince = Date.now(); roomIdleSince = Date.now();
@@ -590,6 +682,8 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null)
chrome.storage.session.set({ chrome.storage.session.set({
currentTabId, currentTabId,
currentTabTitle, currentTabTitle,
currentTargetFrameId,
currentTargetDocumentId,
roomIdleSince, roomIdleSince,
lastContentHeartbeatAt lastContentHeartbeatAt
}).catch(() => {}); }).catch(() => {});
@@ -615,10 +709,11 @@ async function leaveRoomAfterIdleGrace(reason) {
// Notify content.js/popup BEFORE currentTabId is cleared so they can reset // Notify content.js/popup BEFORE currentTabId is cleared so they can reset
// any stale guest-side HCM state (dialog/badge/desync) — H-2. // any stale guest-side HCM state (dialog/badge/desync) — H-2.
broadcastControlMode(); broadcastControlMode();
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {}); if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_DESTROY' }).catch(() => {});
invalidateTargetActivations(); invalidateTargetActivations();
currentTabId = null; currentTabId = null;
currentTabTitle = null; currentTabTitle = null;
clearCurrentContentTarget();
roomIdleSince = null; roomIdleSince = null;
lastContentHeartbeatAt = null; lastContentHeartbeatAt = null;
clearEpisodeLobbyState(); clearEpisodeLobbyState();
@@ -627,6 +722,8 @@ async function leaveRoomAfterIdleGrace(reason) {
currentRoom: null, currentRoom: null,
currentTabId: null, currentTabId: null,
currentTabTitle: null, currentTabTitle: null,
currentTargetFrameId: 0,
currentTargetDocumentId: null,
roomIdleSince: null, roomIdleSince: null,
lastContentHeartbeatAt: null, lastContentHeartbeatAt: null,
episodeLobby: null, episodeLobby: null,
@@ -846,7 +943,7 @@ function broadcastControlMode() {
chrome.runtime.sendMessage(payload).catch(() => {}); chrome.runtime.sendMessage(payload).catch(() => {});
if (currentTabId) { if (currentTabId) {
const tabId = parseInt(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'; status = 'idle';
} }
chrome.runtime.sendMessage({ type: 'CONNECTION_STATUS', status }).catch(() => {}); 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(); updateBadgeStatus();
} }
@@ -1121,7 +1218,7 @@ async function handleServerEvent(event, data) {
hostPeerId = data.hostPeerId || null; hostPeerId = data.hostPeerId || null;
controllers = Array.isArray(data.controllers) ? data.controllers : []; controllers = Array.isArray(data.controllers) ? data.controllers : [];
serverCapabilities = Array.isArray(data.capabilities) ? data.capabilities : []; 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(); hcmEnforceDesyncInvariant();
broadcastControlMode(); broadcastControlMode();
markRoomPotentiallyIdle(); markRoomPotentiallyIdle();
@@ -1156,7 +1253,7 @@ async function handleServerEvent(event, data) {
if (currentTabId) { if (currentTabId) {
const tabId = parseInt(currentTabId); const tabId = parseInt(currentTabId);
if (!isNaN(tabId)) { if (!isNaN(tabId)) {
chrome.tabs.sendMessage(tabId, { sendMessageToContentTab(tabId, {
type: 'EPISODE_LOBBY', type: 'EPISODE_LOBBY',
expectedTitle: episodeLobby.expectedTitle expectedTitle: episodeLobby.expectedTitle
}).catch(() => {}); }).catch(() => {});
@@ -1224,7 +1321,7 @@ async function handleServerEvent(event, data) {
(typeof candidate === 'object' ? candidate.peerId : candidate) === received.senderId (typeof candidate === 'object' ? candidate.peerId : candidate) === received.senderId
); );
if (Number.isInteger(tabId)) { if (Number.isInteger(tabId)) {
chrome.tabs.sendMessage(tabId, { sendMessageToContentTab(tabId, {
type: 'CHAT_MESSAGE', type: 'CHAT_MESSAGE',
message: { message: {
id: received.id, id: received.id,
@@ -1408,7 +1505,7 @@ async function handleServerEvent(event, data) {
// current playback state so the newcomer syncs immediately // current playback state so the newcomer syncs immediately
// instead of waiting up to a full heartbeat interval. // instead of waiting up to a full heartbeat interval.
if (wasSolo && currentTabId) { if (wasSolo && currentTabId) {
chrome.tabs.sendMessage(currentTabId, { type: 'REQUEST_HEARTBEAT' }).catch(() => {}); sendMessageToCurrentContent({ type: 'REQUEST_HEARTBEAT' }).catch(() => {});
} }
if (episodeLobby && episodeLobby.initiatorPeerId === peerId) { if (episodeLobby && episodeLobby.initiatorPeerId === peerId) {
@@ -1498,7 +1595,7 @@ async function handleServerEvent(event, data) {
if (currentTabId) { if (currentTabId) {
const tabId = parseInt(currentTabId); const tabId = parseInt(currentTabId);
if (!isNaN(tabId)) { if (!isNaN(tabId)) {
chrome.tabs.sendMessage(tabId, { sendMessageToContentTab(tabId, {
type: 'EPISODE_LOBBY', type: 'EPISODE_LOBBY',
expectedTitle: data.expectedTitle expectedTitle: data.expectedTitle
}).catch(() => {}); }).catch(() => {});
@@ -1610,7 +1707,7 @@ function clearEpisodeLobbyState() {
if (currentTabId) { if (currentTabId) {
const tabId = parseInt(currentTabId); const tabId = parseInt(currentTabId);
if (!isNaN(tabId)) { 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); const tabId = normalizeTabId(currentTabId);
if (tabId === null) return; 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 actionTimestamp = payload?.actionTimestamp || Date.now();
const commandSenderId = payload?.senderId || null; const commandSenderId = payload?.senderId || null;
@@ -1749,7 +1858,7 @@ async function routeToContent(action, payload) {
function getTabVideoState(tabId) { function getTabVideoState(tabId) {
return new Promise((resolve) => { return new Promise((resolve) => {
chrome.tabs.sendMessage(tabId, { type: 'GET_VIDEO_STATE' }, (res) => { sendMessageToContentTab(tabId, { type: 'GET_VIDEO_STATE' }, (res) => {
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
resolve({ error: chrome.runtime.lastError.message }); resolve({ error: chrome.runtime.lastError.message });
return; return;
@@ -1761,7 +1870,7 @@ function getTabVideoState(tabId) {
async function getReadyTabVideoState(tabId, expectedGeneration = targetActivationGeneration) { async function getReadyTabVideoState(tabId, expectedGeneration = targetActivationGeneration) {
let state = await getTabVideoState(tabId); let state = await getTabVideoState(tabId);
if (!state || state.error) { if (!state || state.error || state.found === false) {
const activation = await reactivateCurrentTarget(tabId, { expectedGeneration }); const activation = await reactivateCurrentTarget(tabId, { expectedGeneration });
if (activation?.status !== 'ok') { if (activation?.status !== 'ok') {
return { error: 'Target tab changed before content script recovery completed' }; return { error: 'Target tab changed before content script recovery completed' };
@@ -1925,11 +2034,33 @@ async function injectContentScript(tabId, { requestHostAccess = true } = {}) {
let needsPageApiSeek = false; let needsPageApiSeek = false;
let pageApiSeekReady = false; let pageApiSeekReady = false;
let access = null; let access = null;
let scriptTarget = { tabId };
try { try {
access = await inspectTabHostAccess(chrome, tabId); access = await inspectTabHostAccess(chrome, tabId);
const url = access.url || ''; const url = access.url || '';
needsPageApiSeek = shouldUsePageApiSeek(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. // Fall through to the generic content script injection.
} }
@@ -1953,18 +2084,34 @@ async function injectContentScript(tabId, { requestHostAccess = true } = {}) {
} }
await chrome.scripting.executeScript({ await chrome.scripting.executeScript({
target: { tabId }, target: scriptTarget,
files: ['page-api-seek-overrides.js'] files: ['page-api-seek-overrides.js']
}); });
await chrome.scripting.executeScript({ await chrome.scripting.executeScript({
target: { tabId }, target: scriptTarget,
func: setPageApiSeekEnabled, func: setPageApiSeekEnabled,
args: [pageApiSeekReady] args: [pageApiSeekReady]
}); });
return await chrome.scripting.executeScript({ const injectionResults = await chrome.scripting.executeScript({
target: { tabId }, target: scriptTarget,
files: ['chat-format.js', 'chat-overlay.js', 'content.js'] 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) { } catch (error) {
// A temporary activeTab grant is intentionally allowed to win: even if // A temporary activeTab grant is intentionally allowed to win: even if
// permissions.contains() reports false, a successful injection above is // permissions.contains() reports false, a successful injection above is
@@ -2151,10 +2298,12 @@ async function activateTargetTab(tabId, tabTitle, {
const activationGeneration = ++targetActivationGeneration; const activationGeneration = ++targetActivationGeneration;
activeTargetActivation = { generation: activationGeneration, tabId: selectedTabId }; activeTargetActivation = { generation: activationGeneration, tabId: selectedTabId };
const previousTabId = normalizeTabId(currentTabId); const previousTabId = normalizeTabId(currentTabId);
const previousContentTarget = currentContentTarget();
let injectedContentTarget = { frameId: 0, documentId: null };
try { try {
try { try {
await injectContentScript(selectedTabId, { requestHostAccess }); injectedContentTarget = await injectContentScript(selectedTabId, { requestHostAccess });
} catch (error) { } catch (error) {
if (activationGeneration !== targetActivationGeneration) { if (activationGeneration !== targetActivationGeneration) {
if (error?.code === HOST_ACCESS_REQUIRED_STATUS if (error?.code === HOST_ACCESS_REQUIRED_STATUS
@@ -2170,6 +2319,7 @@ async function activateTargetTab(tabId, tabTitle, {
} }
currentTabId = null; currentTabId = null;
currentTabTitle = null; currentTabTitle = null;
clearCurrentContentTarget();
lastContentHeartbeatAt = null; lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now(); if (currentRoom) roomIdleSince = Date.now();
if (previousTabId) { if (previousTabId) {
@@ -2179,6 +2329,8 @@ async function activateTargetTab(tabId, tabTitle, {
await chrome.storage.session.set({ await chrome.storage.session.set({
currentTabId: null, currentTabId: null,
currentTabTitle: null, currentTabTitle: null,
currentTargetFrameId: 0,
currentTargetDocumentId: null,
roomIdleSince, roomIdleSince,
lastContentHeartbeatAt: null lastContentHeartbeatAt: null
}); });
@@ -2216,7 +2368,7 @@ async function activateTargetTab(tabId, tabTitle, {
return { status: 'superseded' }; return { status: 'superseded' };
} }
await applyAudioSettingsToTab(selectedTabId); await applyAudioSettingsToTab(selectedTabId, injectedContentTarget);
if (activationGeneration !== targetActivationGeneration) { if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
return { status: 'superseded' }; return { status: 'superseded' };
@@ -2231,8 +2383,22 @@ async function activateTargetTab(tabId, tabTitle, {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
return { status: 'superseded' }; return { status: 'superseded' };
} }
if (previousTabId === selectedTabId && !sameContentTarget(previousContentTarget, injectedContentTarget)) {
sendMessageToFrame(
previousTabId,
previousContentTarget.frameId,
{ type: 'KOALASYNC_DEACTIVATE' },
null,
previousContentTarget.documentId
).catch(() => {});
}
currentTabId = selectedTabId; currentTabId = selectedTabId;
currentTabTitle = typeof tabTitle === 'string' ? tabTitle : null; currentTabTitle = typeof tabTitle === 'string' ? tabTitle : null;
currentTargetFrameId = normalizeFrameId(injectedContentTarget.frameId);
currentTargetDocumentId = typeof injectedContentTarget.documentId === 'string'
? injectedContentTarget.documentId
: null;
lastContentHeartbeatAt = null; lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now(); if (currentRoom) roomIdleSince = Date.now();
if (previousTabId && previousTabId !== selectedTabId) { if (previousTabId && previousTabId !== selectedTabId) {
@@ -2242,6 +2408,8 @@ async function activateTargetTab(tabId, tabTitle, {
await chrome.storage.session.set({ await chrome.storage.session.set({
currentTabId, currentTabId,
currentTabTitle, currentTabTitle,
currentTargetFrameId,
currentTargetDocumentId,
roomIdleSince, roomIdleSince,
lastContentHeartbeatAt lastContentHeartbeatAt
}); });
@@ -2281,9 +2449,8 @@ async function retryPendingTarget({ expectedRequestId = null, requireGrantedAcce
} }
if (requireGrantedAccess) { if (requireGrantedAccess) {
let access;
try { try {
access = await inspectTabHostAccess(chrome, pending.tabId); await chrome.tabs.get(pending.tabId);
} catch { } catch {
await clearPendingTarget({ await clearPendingTarget({
expectedRequestId: pending.requestId, expectedRequestId: pending.requestId,
@@ -2291,7 +2458,8 @@ async function retryPendingTarget({ expectedRequestId = null, requireGrantedAcce
}); });
return { status: 'invalid_tab' }; 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' }; return { status: 'permission_not_granted' };
} }
pending = await readPendingTarget(); pending = await readPendingTarget();
@@ -2366,6 +2534,7 @@ if (chrome.tabs?.onRemoved?.addListener) {
if (isCurrent) { if (isCurrent) {
currentTabId = null; currentTabId = null;
currentTabTitle = null; currentTabTitle = null;
clearCurrentContentTarget();
lastContentHeartbeatAt = null; lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now(); if (currentRoom) roomIdleSince = Date.now();
} }
@@ -2421,7 +2590,7 @@ if (chrome.tabs?.onRemoved?.addListener) {
function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries, targetGeneration) { function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries, targetGeneration) {
if (!isCurrentTargetIdentity(tabId, targetGeneration)) return; if (!isCurrentTargetIdentity(tabId, targetGeneration)) return;
chrome.tabs.sendMessage(tabId, { sendMessageToContentTab(tabId, {
type: 'SERVER_COMMAND', type: 'SERVER_COMMAND',
action, action,
payload, payload,
@@ -2496,7 +2665,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
function leaveOldRoomIfSwitching(newRoomId) { function leaveOldRoomIfSwitching(newRoomId) {
if (currentRoom && currentRoom.roomId !== 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'); addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
forceDisconnect(); forceDisconnect();
currentRoom = null; currentRoom = null;
@@ -2531,17 +2700,36 @@ function leaveOldRoomIfSwitching(newRoomId) {
function resetAudioProcessingInTab(tabId) { function resetAudioProcessingInTab(tabId) {
if (!tabId) return; if (!tabId) return;
if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) {
sendMessageToCurrentContent({ action: 'RESET_AUDIO_PROCESSING' }).catch(() => {});
return;
}
chrome.tabs.sendMessage(tabId, { action: 'RESET_AUDIO_PROCESSING' }).catch(() => {}); chrome.tabs.sendMessage(tabId, { action: 'RESET_AUDIO_PROCESSING' }).catch(() => {});
} }
async function applyAudioSettingsToTab(tabId) { async function applyAudioSettingsToTab(tabId, contentTarget = null) {
if (!tabId) return; if (!tabId) return;
// Local-only: audioSettings are never read from storage.sync. // Local-only: audioSettings are never read from storage.sync.
const data = await chrome.storage.local.get(['audioSettings']); const data = await chrome.storage.local.get(['audioSettings']);
chrome.tabs.sendMessage(tabId, { const message = {
action: 'APPLY_AUDIO_SETTINGS', action: 'APPLY_AUDIO_SETTINGS',
settings: data.audioSettings 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 --- // --- Extension Message Listeners ---
@@ -2557,7 +2745,7 @@ chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'local' || (!changes.roomId && !changes.chatKey && !changes.chatEnabled)) return; if (area !== 'local' || (!changes.roomId && !changes.chatKey && !changes.chatEnabled)) return;
if (changes.chatKey) chatSecretGuard = validateChatSecret(changes.chatKey.newValue); if (changes.chatKey) chatSecretGuard = validateChatSecret(changes.chatKey.newValue);
invalidateChatSession(); 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) { 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) { if (settings.roomId && currentRoom && currentRoom.roomId === settings.roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
broadcastConnectionStatus('connected'); 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)); const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
for (const tab of tabs) { for (const tab of tabs) {
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {}); 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 chatEnabled: settings.chatEnabled
}); });
} else if (message.type === 'GET_CHAT_CONTEXT') { } else if (message.type === 'GET_CHAT_CONTEXT') {
const senderTabId = sender.tab?.id; if (!currentRoom || !currentTabId || !isCurrentContentSender(sender)) {
if (!currentRoom || !currentTabId || senderTabId !== Number(currentTabId)) {
sendResponse({ supported: false, hasKey: false }); sendResponse({ supported: false, hasKey: false });
return; return;
} }
@@ -2692,8 +2879,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} }
}); });
} else if (message.type === 'CHAT_SEND') { } else if (message.type === 'CHAT_SEND') {
const senderTabId = sender.tab?.id; if (!currentRoom || !currentTabId || !isCurrentContentSender(sender)) {
if (!currentRoom || !currentTabId || senderTabId !== Number(currentTabId)) {
sendResponse({ status: 'invalid_tab' }); sendResponse({ status: 'invalid_tab' });
return; return;
} }
@@ -2784,7 +2970,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
// "Solo" to the host (stale-badge split-brain). // "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) }); 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') { } 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 }); sendResponse({ status: 'ignored_unselected_tab', target: null });
return; return;
} }
@@ -2811,7 +2997,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} else if (message.type === 'HCM_DESYNC_STATE') { } else if (message.type === 'HCM_DESYNC_STATE') {
// content.js tells us whether the local user chose to watch on their own. // content.js tells us whether the local user chose to watch on their own.
// Only accept from the currently selected tab. // 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' }); sendResponse({ status: 'ignored_unselected_tab' });
return; return;
} }
@@ -2839,10 +3025,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
// Notify content.js/popup BEFORE currentTabId is cleared so they drop any // Notify content.js/popup BEFORE currentTabId is cleared so they drop any
// stale guest-side HCM state (dialog/badge/desync) — H-2/H-3. // stale guest-side HCM state (dialog/badge/desync) — H-2/H-3.
broadcastControlMode(); broadcastControlMode();
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {}); if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_DESTROY' }).catch(() => {});
invalidateTargetActivations(); invalidateTargetActivations();
currentTabId = null; currentTabId = null;
currentTabTitle = null; currentTabTitle = null;
clearCurrentContentTarget();
roomIdleSince = null; roomIdleSince = null;
lastContentHeartbeatAt = 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) { if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
broadcastConnectionStatus('connected'); 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)); const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
if (!isCurrentJoin()) { if (!isCurrentJoin()) {
sendResponse({ status: 'superseded' }); sendResponse({ status: 'superseded' });
@@ -2988,12 +3175,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ error: 'No tabId provided' }); sendResponse({ error: 'No tabId provided' });
return; return;
} }
chrome.tabs.sendMessage(tabId, { type: 'GET_VIDEO_STATE' }, (res) => { getReadyTabVideoState(tabId).then(res => {
if (chrome.runtime.lastError) { sendResponse(res);
sendResponse({ error: chrome.runtime.lastError.message }); }).catch(error => {
} else { sendResponse({ error: error.message });
sendResponse(res);
}
}); });
} else if (message.type === 'DEV_SIMULATE_REMOTE_SEEK') { } else if (message.type === 'DEV_SIMULATE_REMOTE_SEEK') {
if (!(await devRemoteToolsAllowed())) { if (!(await devRemoteToolsAllowed())) {
@@ -3029,11 +3214,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
HOST_ONLY_GATED_ACTIONS.includes(message.action)) { HOST_ONLY_GATED_ACTIONS.includes(message.action)) {
addLog(`Host-only: blocked local ${message.action} (you are a guest)`, 'warn'); addLog(`Host-only: blocked local ${message.action} (you are a guest)`, 'warn');
if (sender.tab && sender.tab.id) { if (sender.tab && sender.tab.id) {
chrome.tabs.sendMessage(sender.tab.id, { sendMessageToFrame(sender.tab.id, sender.frameId, {
type: 'HOST_BLOCKED', type: 'HOST_BLOCKED',
action: message.action, action: message.action,
target: getHostSyncTarget() target: getHostSyncTarget()
}).catch(() => {}); }, null, sender.documentId).catch(() => {});
} }
sendResponse({ status: 'blocked_host_only' }); sendResponse({ status: 'blocked_host_only' });
return; return;
@@ -3141,9 +3326,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}; };
if (sender.tab) { if (sender.tab) {
const senderTabId = sender.tab.id; if (!isCurrentContentSender(sender)) {
if (!currentTabId || currentTabId !== senderTabId) {
sendResponse({ status: 'ignored_unselected_tab' }); sendResponse({ status: 'ignored_unselected_tab' });
return; return;
} }
@@ -3162,7 +3345,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}); });
} }
} else if (message.type === 'FORCE_SYNC_ACK') { } 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' }); sendResponse({ status: 'ignored_unselected_tab' });
return; return;
} }
@@ -3190,7 +3373,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} }
sendResponse({ status: 'ok' }); sendResponse({ status: 'ok' });
} else if (message.type === 'CMD_ACK') { } 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' }); sendResponse({ status: 'ignored_unselected_tab' });
return; return;
} }
@@ -3210,9 +3393,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'ok' }); sendResponse({ status: 'ok' });
} else if (message.type === 'HEARTBEAT') { } else if (message.type === 'HEARTBEAT') {
if (sender.tab) { if (sender.tab) {
const senderTabId = sender.tab.id; if (!isCurrentContentSender(sender)) {
if (!currentTabId || currentTabId !== senderTabId) {
sendResponse({ status: 'ignored_unselected_tab' }); sendResponse({ status: 'ignored_unselected_tab' });
return; return;
} }
@@ -3287,6 +3468,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
invalidateTargetActivations(); invalidateTargetActivations();
currentTabId = null; currentTabId = null;
currentTabTitle = null; currentTabTitle = null;
clearCurrentContentTarget();
lastContentHeartbeatAt = null; lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now(); if (currentRoom) roomIdleSince = Date.now();
if (previousTabId) { if (previousTabId) {
@@ -3297,6 +3479,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
await chrome.storage.session.set({ await chrome.storage.session.set({
currentTabId: null, currentTabId: null,
currentTabTitle: null, currentTabTitle: null,
currentTargetFrameId: 0,
currentTargetDocumentId: null,
roomIdleSince, roomIdleSince,
lastContentHeartbeatAt: null lastContentHeartbeatAt: null
}); });
@@ -3324,8 +3508,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} else if (message.type === 'EPISODE_CHANGED') { } else if (message.type === 'EPISODE_CHANGED') {
// Content script detected an episode transition // Content script detected an episode transition
if (sender.tab) { if (sender.tab) {
const senderTabId = sender.tab.id; if (!isCurrentContentSender(sender)) {
if (!currentTabId || currentTabId !== senderTabId) {
sendResponse({ status: 'ignored_unselected_tab' }); sendResponse({ status: 'ignored_unselected_tab' });
return; return;
} }
@@ -3410,10 +3593,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
// Tell content script to pause the video and start polling // Tell content script to pause the video and start polling
// (This is the only place we pause — after confirming the feature is enabled) // (This is the only place we pause — after confirming the feature is enabled)
if (sender.tab && sender.tab.id) { if (sender.tab && sender.tab.id) {
chrome.tabs.sendMessage(sender.tab.id, { sendMessageToFrame(sender.tab.id, sender.frameId, {
type: 'PAUSE_FOR_LOBBY', type: 'PAUSE_FOR_LOBBY',
expectedTitle: lobbyTitle expectedTitle: lobbyTitle
}).catch(() => {}); }, null, sender.documentId).catch(() => {});
} }
// Broadcast to room // Broadcast to room
@@ -3428,8 +3611,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'lobby_created' }); sendResponse({ status: 'lobby_created' });
} else if (message.type === 'EPISODE_READY_LOCAL') { } else if (message.type === 'EPISODE_READY_LOCAL') {
if (sender.tab) { if (sender.tab) {
const senderTabId = sender.tab.id; if (!isCurrentContentSender(sender)) {
if (!currentTabId || currentTabId !== senderTabId) {
sendResponse({ status: 'ignored_unselected_tab' }); sendResponse({ status: 'ignored_unselected_tab' });
return; return;
} }
@@ -3468,13 +3650,27 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}); });
} }
if (currentRoom && currentTabId) { if (currentRoom && currentTabId) {
chrome.tabs.sendMessage(currentTabId, { type: 'REQUEST_HEARTBEAT' }).catch(() => {}); sendMessageToCurrentContent({ type: 'REQUEST_HEARTBEAT' }).catch(() => {});
} }
sendResponse({ status: 'ok' }); 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') { } else if (message.type === 'CONTENT_BOOT') {
if (sender.tab) { if (sender.tab) {
const senderTabId = sender.tab.id; if (!isCurrentContentSender(sender)) {
if (!currentTabId || currentTabId !== senderTabId) {
sendResponse({ status: 'ignored_unselected_tab' }); sendResponse({ status: 'ignored_unselected_tab' });
return; return;
} }
@@ -3501,7 +3697,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
initTabManager({ initTabManager({
getCurrentTabId: () => currentTabId, getCurrentTabId: () => currentTabId,
reactivateCurrentTarget, reactivateCurrentTarget,
ensureState ensureState,
sendToCurrentContent: sendMessageToCurrentContent
}); });
// Initial Connect — only if user has an active room configuration // Initial Connect — only if user has an active room configuration
+46 -1
View File
@@ -7,12 +7,14 @@
*/ */
(function() { (function() {
// Injection Guard: Check if already injected AND context is valid // Injection Guard: Check if already injected AND context is valid
try { try {
if (window.koalaSyncInjected && chrome.runtime.id) {
window.koalaSyncTargetActive = true; window.koalaSyncTargetActive = true;
return; return;
} }
@@ -90,6 +92,31 @@
delete _suppressTimers[state]; delete _suppressTimers[state];
} }
}
// --- Seek Relay Filtering ---
// Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks
// from being relayed to peers as user-initiated seeks.
const MIN_SEEK_DELTA = 2.0;
let lastReportedSeekTime = null; // last currentTime we relayed as a SEEK
let seekDebounceTimer = null; // debounce timer for rapid seek events
let expectedSeekTime = null; // strictly track programmatic seeks
const PAGE_API_SEEK_BRIDGE = 1;
// Accurate Disney+ playhead pushed by the MAIN-world page-API bridge
// (background.js installPageApiSeekBridge). The isolated content world
// can't read the page's media player directly.
let disneyPageApiTime = null; let disneyPageApiTime = null;
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('message', (event) => { window.addEventListener('message', (event) => {
@@ -856,6 +883,12 @@
} }
}
return;
}
const host = hcmEl('div', 'all:initial'); const host = hcmEl('div', 'all:initial');
const root = host.attachShadow({ mode: 'open' }); const root = host.attachShadow({ mode: 'open' });
@@ -865,7 +898,9 @@
b.append(hcmEl('span', null, '● ' + hcmStrings.badge), hcmEl('span', 'text-decoration:underline', hcmStrings.resync)); b.append(hcmEl('span', null, '● ' + hcmStrings.badge), hcmEl('span', 'text-decoration:underline', hcmStrings.resync));
b.addEventListener('click', hcmExitDesync); b.addEventListener('click', hcmExitDesync);
root.appendChild(b);
document.body.appendChild(host); document.body.appendChild(host);
hcmBadgeHost = host; hcmBadgeHost = host;
@@ -953,6 +988,12 @@
if (candidates.length === 0) return null; if (candidates.length === 0) return null;
// Multiple videos found → pick the best one
if (candidates.length === 1) return candidates[0];
@@ -1138,6 +1179,7 @@
src.connect(dryGain); src.connect(dryGain);
dryGain.connect(ctx.destination);
src.connect(compressor); src.connect(compressor);
@@ -1272,6 +1314,7 @@
} }
} }
// --- Episode Auto-Sync: Detection --- // --- Episode Auto-Sync: Detection ---
@@ -1309,6 +1352,7 @@
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`; if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
return null;
} }
@@ -1553,6 +1597,7 @@
playPauseButtonSelector: '.ytp-play-button' playPauseButtonSelector: '.ytp-play-button'
}, },
{ {
name: 'twitch-player-buttons',
urls: ['twitch.tv'], urls: ['twitch.tv'],
playPauseButtonSelector: '[data-a-target="player-play-pause-button"]' playPauseButtonSelector: '[data-a-target="player-play-pause-button"]'
} }
+12
View File
@@ -150,6 +150,18 @@ export function requestOriginPermission(chromeApi, originPattern) {
).catch(() => false); ).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) { export function isHostAccessError(error) {
const message = typeof error?.message === 'string' ? error.message : String(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); return /host permission|permission to access (?:this|the|respective) host|cannot access contents of/i.test(message);
+262
View File
@@ -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;
}
+148
View File
@@ -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 });
});
});
+3 -2
View File
@@ -1,7 +1,8 @@
export function initTabManager({ export function initTabManager({
getCurrentTabId, getCurrentTabId,
reactivateCurrentTarget, reactivateCurrentTarget,
ensureState ensureState,
sendToCurrentContent
}) { }) {
chrome.storage.onChanged.addListener(async (changes, area) => { chrome.storage.onChanged.addListener(async (changes, area) => {
if (area !== 'local' || !changes.audioSettings) return; if (area !== 'local' || !changes.audioSettings) return;
@@ -9,7 +10,7 @@ export function initTabManager({
const tabId = getCurrentTabId(); const tabId = getCurrentTabId();
if (!tabId) return; if (!tabId) return;
chrome.tabs.sendMessage(tabId, { sendToCurrentContent({
action: 'APPLY_AUDIO_SETTINGS', action: 'APPLY_AUDIO_SETTINGS',
settings: changes.audioSettings.newValue settings: changes.audioSettings.newValue
}).catch(() => {}); }).catch(() => {});
+2 -2
View File
@@ -1329,7 +1329,7 @@
} }
@keyframes floatHint { @keyframes floatHint {
0%, 100% { transform: translateY(0); } 0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); } 50% { transform: translateY(-2px); }
} }
</style> </style>
</head> </head>
@@ -1452,7 +1452,7 @@
<option value="" data-i18n="OPTION_SELECT_TAB">-- Select a Tab --</option> <option value="" data-i18n="OPTION_SELECT_TAB">-- Select a Tab --</option>
</select> </select>
<!-- Hint Tooltip --> <!-- Hint Tooltip -->
<div id="targetTabHint" style="display: none; position: absolute; top: -25px; right: 0; background: var(--accent); color: var(--text-on-green); padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: bold; pointer-events: none; animation: floatHint 2s ease-in-out infinite; box-shadow: 0 4px 12px color-mix(in oklch, var(--accent), transparent 60%); z-index: 10;" data-i18n="HINT_SELECT_VIDEO"> <div id="targetTabHint" style="display: none; position: absolute; top: -8px; right: 0; background: var(--accent); color: var(--text-on-green); padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: bold; pointer-events: none; animation: floatHint 2s ease-in-out infinite; box-shadow: 0 2px 6px color-mix(in oklch, var(--accent), transparent 82%); z-index: 10;" data-i18n="HINT_SELECT_VIDEO">
Select your video here! Select your video here!
<div style="position: absolute; bottom: -4px; right: 20px; width: 8px; height: 8px; background: var(--accent); transform: rotate(45deg);"></div> <div style="position: absolute; bottom: -4px; right: 20px; width: 8px; height: 8px; background: var(--accent); transform: rotate(45deg);"></div>
</div> </div>
+7 -15
View File
@@ -1,11 +1,12 @@
import { EVENTS, OFFICIAL_LANDING_PAGE_URL, SUPPORT_URL, getReviewUrl } from './shared/constants.js'; import { EVENTS, OFFICIAL_LANDING_PAGE_URL, SUPPORT_URL, getReviewUrl } from './shared/constants.js';
import { BLACKLIST_DOMAINS } from './shared/blacklist.js'; import { BLACKLIST_ALLOWLIST_DOMAINS, BLACKLIST_DOMAINS } from './shared/blacklist.js';
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js'; import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js';
import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js'; import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js';
import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js'; import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js';
import { normalizeRoomId } from './chat-session.js'; import { normalizeRoomId } from './chat-session.js';
import './shared/invite-links.js'; import './shared/invite-links.js';
import { normalizeTabId, requestOriginPermission } from './host-access.js'; import { normalizeTabId, requestOriginPermission } from './host-access.js';
import { isTabUrlFiltered } from './tab-filter.js';
let pendingInviteRoomId = ''; let pendingInviteRoomId = '';
let pendingInviteChatKey = ''; let pendingInviteChatKey = '';
@@ -1157,18 +1158,9 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
const filteredTabs = tabs.filter(tab => { const filteredTabs = tabs.filter(tab => {
if (!tab.url || tab.url.startsWith('chrome://')) return false; if (!tab.url || tab.url.startsWith('chrome://')) return false;
if (isFilterActive && tab.id !== parseInt(currentTargetTabId)) { if (isFilterActive && tab.id !== parseInt(currentTargetTabId)) {
const urlStr = tab.url.toLowerCase(); if (isTabUrlFiltered(tab.url, BLACKLIST_DOMAINS, BLACKLIST_ALLOWLIST_DOMAINS)) {
if (BLACKLIST_DOMAINS.some(d => { return false;
const domain = d.toLowerCase(); }
try {
const hostname = new URL(tab.url).hostname.toLowerCase();
if (domain.endsWith('.')) return hostname.startsWith(domain) || hostname.includes('.' + domain);
if (domain.includes('.')) return hostname === domain || hostname.endsWith('.' + domain);
} catch {
/* ignore invalid URLs */
}
return urlStr.includes(domain);
})) return false;
} }
return true; return true;
}); });
@@ -2073,7 +2065,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
failForceSyncTime(); failForceSyncTime();
return; return;
} }
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => { chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId }, (retryResponse) => {
if (chrome.runtime.lastError || !retryResponse || !Number.isFinite(retryResponse.currentTime)) { if (chrome.runtime.lastError || !retryResponse || !Number.isFinite(retryResponse.currentTime)) {
failForceSyncTime(); failForceSyncTime();
return; return;
@@ -2081,7 +2073,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
sendForceSync(retryResponse.currentTime); sendForceSync(retryResponse.currentTime);
}); });
}; };
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (response) => { chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId }, (response) => {
if (Number.isFinite(response?.currentTime)) { if (Number.isFinite(response?.currentTime)) {
sendForceSync(response.currentTime); sendForceSync(response.currentTime);
return; return;
+22
View File
@@ -0,0 +1,22 @@
function hostnameMatchesDomain(hostname, domain) {
const normalizedHostname = String(hostname || '').toLowerCase();
const normalizedDomain = String(domain || '').toLowerCase();
return normalizedDomain
&& (normalizedHostname === normalizedDomain
|| normalizedHostname.endsWith(`.${normalizedDomain}`));
}
export function isTabUrlFiltered(url, blacklistDomains, allowlistDomains = []) {
const urlString = String(url || '');
try {
const hostname = new URL(urlString).hostname.toLowerCase();
if (allowlistDomains.some(domain => hostnameMatchesDomain(hostname, domain))) {
return false;
}
return blacklistDomains.some(domain => hostnameMatchesDomain(hostname, domain));
} catch {
const normalizedUrl = urlString.toLowerCase();
return blacklistDomains.some(domain => normalizedUrl.includes(String(domain).toLowerCase()));
}
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { isTabUrlFiltered } from './tab-filter.js';
const blacklist = ['google.com', 'mail.google.com'];
const allowlist = ['drive.google.com'];
describe('tab URL filtering', () => {
it('keeps Google Drive selectable despite the google.com parent-domain rule', () => {
expect(isTabUrlFiltered(
'https://drive.google.com/drive/u/1/search?q=mp4',
blacklist,
allowlist
)).toBe(false);
});
it('continues to filter other Google subdomains', () => {
expect(isTabUrlFiltered('https://mail.google.com/mail/u/0/', blacklist, allowlist)).toBe(true);
expect(isTabUrlFiltered('https://www.google.com/search?q=video', blacklist, allowlist)).toBe(true);
});
});
+26 -3
View File
@@ -98,14 +98,15 @@ function loadTimelineFns(hostname, document = makeDocument(), pageApiTime = null
].join('\n'))({ location: { hostname } }, document); ].join('\n'))({ location: { hostname } }, document);
} }
function loadPlayerFixFns(hostname) { function loadPlayerFixFns(hostname, href = `https://${hostname}/`) {
return Function('window', [ return Function('window', [
extractFunction('hostMatchesUrl', source), extractFunction('hostMatchesUrl', source),
extractFunction('matchesPlayerUrls', source), extractFunction('matchesPlayerUrls', source),
extractFunction('isGoogleDrivePlayerFrame', source),
extractFunction('getPlayerActionFixes', source), extractFunction('getPlayerActionFixes', source),
extractFunction('getActivePlayerActionFix', source), extractFunction('getActivePlayerActionFix', source),
'return { getPlayerActionFixes, getActivePlayerActionFix };' 'return { isGoogleDrivePlayerFrame, getPlayerActionFixes, getActivePlayerActionFix };'
].join('\n'))({ location: { hostname } }); ].join('\n'))({ location: { hostname, href } });
} }
const disneyFns = loadTimelineFns('www.disneyplus.com', makeDocument(), { const disneyFns = loadTimelineFns('www.disneyplus.com', makeDocument(), {
@@ -147,6 +148,28 @@ const twitchFixFns = loadPlayerFixFns('player.twitch.tv');
assert.equal(twitchFixFns.getActivePlayerActionFix().name, 'twitch-player-buttons'); assert.equal(twitchFixFns.getActivePlayerActionFix().name, 'twitch-player-buttons');
assert.deepEqual(twitchFixFns.getActivePlayerActionFix().urls, ['twitch.tv']); assert.deepEqual(twitchFixFns.getActivePlayerActionFix().urls, ['twitch.tv']);
const googleDriveFixFns = loadPlayerFixFns(
'youtube.googleapis.com',
'https://youtube.googleapis.com/embed/?origin=https%3A%2F%2Fdrive.google.com'
);
assert.equal(googleDriveFixFns.getActivePlayerActionFix().name, 'google-drive-player-buttons');
assert.deepEqual(googleDriveFixFns.getActivePlayerActionFix().urls, ['youtube.googleapis.com']);
const unrelatedGoogleEmbedFixFns = loadPlayerFixFns(
'youtube.googleapis.com',
'https://youtube.googleapis.com/embed/?origin=https%3A%2F%2Fexample.com'
);
assert.equal(unrelatedGoogleEmbedFixFns.getActivePlayerActionFix(), null);
const googleDrivePlayerFns = Function('window', [
extractFunction('isGoogleDrivePlayerFrame', source),
'return { isGoogleDrivePlayerFrame };'
].join('\n'))({
location: {
hostname: 'youtube.googleapis.com',
href: 'https://youtube.googleapis.com/embed/?origin=https%3A%2F%2Fdrive.google.com'
}
});
assert.equal(googleDrivePlayerFns.isGoogleDrivePlayerFrame(), true);
const genericFixFns = loadPlayerFixFns('example.com'); const genericFixFns = loadPlayerFixFns('example.com');
assert.equal(genericFixFns.getActivePlayerActionFix(), null); assert.equal(genericFixFns.getActivePlayerActionFix(), null);
+12
View File
@@ -5,6 +5,7 @@ import { cwd } from 'node:process';
import { import {
HOST_ACCESS_REQUIRED_STATUS, HOST_ACCESS_REQUIRED_STATUS,
addTabHostAccessRequest, addTabHostAccessRequest,
containsOriginPermission,
describeTabUrl, describeTabUrl,
inspectTabHostAccess, inspectTabHostAccess,
isHostAccessError, isHostAccessError,
@@ -122,6 +123,17 @@ const callbackPermissionChrome = {
}; };
assert.equal(await requestOriginPermission(callbackPermissionChrome, 'https://video.example/*'), true); assert.equal(await requestOriginPermission(callbackPermissionChrome, 'https://video.example/*'), true);
assert.equal(await requestOriginPermission({ permissions: {} }, 'https://video.example/*'), null); assert.equal(await requestOriginPermission({ permissions: {} }, 'https://video.example/*'), null);
let containedOriginRequest = null;
assert.equal(await containsOriginPermission({
permissions: {
contains: async request => {
containedOriginRequest = request;
return true;
}
}
}, 'https://youtube.googleapis.com/*'), true);
assert.deepEqual(containedOriginRequest, { origins: ['https://youtube.googleapis.com/*'] });
assert.equal(await containsOriginPermission({ permissions: {} }, 'https://youtube.googleapis.com/*'), null);
const background = fs.readFileSync(path.join(cwd(), 'extension', 'background.js'), 'utf8'); const background = fs.readFileSync(path.join(cwd(), 'extension', 'background.js'), 'utf8');
const popup = fs.readFileSync(path.join(cwd(), 'extension', 'popup.js'), 'utf8'); const popup = fs.readFileSync(path.join(cwd(), 'extension', 'popup.js'), 'utf8');
+6
View File
@@ -178,3 +178,9 @@ export const BLACKLIST_DOMAINS = [
'lichess.org', 'lichess.org',
'skribbl.io' 'skribbl.io'
]; ];
// Explicit exceptions for useful media pages whose parent domain is filtered.
// Google Drive is otherwise matched indirectly by the broad `google.com` entry.
export const BLACKLIST_ALLOWLIST_DOMAINS = [
'drive.google.com'
];