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 { 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