Harden host access recovery races

This commit is contained in:
Timo
2026-07-15 13:31:56 +02:00
parent 61d0d64c7d
commit fe13275b5d
5 changed files with 637 additions and 233 deletions
+430 -130
View File
@@ -63,6 +63,7 @@ let currentRoom = null;
let currentTabId = null;
let currentTabTitle = null; // New: for Smart Matching
let targetActivationGeneration = 0;
let activeTargetActivation = null;
let logs = [];
let history = []; // New: for Action History
let storageInitialized = false;
@@ -522,8 +523,27 @@ function markRoomPotentiallyIdle() {
}
}
function clearTargetTabForIdle() {
function invalidateTargetActivations() {
targetActivationGeneration++;
activeTargetActivation = null;
return targetActivationGeneration;
}
function isCurrentTargetIdentity(tabId, generation) {
return normalizeTabId(currentTabId) === normalizeTabId(tabId)
&& targetActivationGeneration === generation;
}
function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) {
if (expectedTabId !== null && normalizeTabId(currentTabId) !== normalizeTabId(expectedTabId)) {
return false;
}
if (expectedGeneration !== null && targetActivationGeneration !== expectedGeneration) {
return false;
}
completeForceSyncBeforeTargetChange(null);
invalidateTargetActivations();
clearPendingTarget().catch(() => {});
currentTabId = null;
currentTabTitle = null;
@@ -538,6 +558,7 @@ function clearTargetTabForIdle() {
lastContentHeartbeatAt
}).catch(() => {});
updateBadgeStatus();
return true;
}
async function leaveRoomAfterIdleGrace(reason) {
@@ -546,6 +567,7 @@ async function leaveRoomAfterIdleGrace(reason) {
reconnectFailed = false;
reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
completeForceSyncBeforeTargetChange(null);
emit(EVENTS.LEAVE_ROOM, { peerId });
forceDisconnect();
currentRoom = null;
@@ -557,7 +579,7 @@ 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();
targetActivationGeneration++;
invalidateTargetActivations();
currentTabId = null;
currentTabTitle = null;
roomIdleSince = null;
@@ -1455,6 +1477,16 @@ function executeForceSync() {
addLog('Force Sync Executed', 'success');
}
function completeForceSyncBeforeTargetChange(nextTabId) {
if (!isForceSyncInitiator) return;
const selectedTabId = normalizeTabId(currentTabId);
const normalizedNextTabId = normalizeTabId(nextTabId);
if (selectedTabId !== null && selectedTabId === normalizedNextTabId) return;
addLog('Finishing Force Sync before target change', 'info');
executeForceSync();
}
// --- Episode Auto-Sync Lobby Functions ---
function persistEpisodeLobby() {
if (storageInitialized) chrome.storage.session.set({ episodeLobby });
@@ -1602,13 +1634,14 @@ function updateLastAction(action, senderId, timestamp = Date.now()) {
async function routeToContent(action, payload) {
if (!currentTabId) return;
const tabId = parseInt(currentTabId);
if (isNaN(tabId)) return;
const tabId = normalizeTabId(currentTabId);
if (tabId === null) return;
const targetGeneration = targetActivationGeneration;
const actionTimestamp = payload?.actionTimestamp || Date.now();
const commandSenderId = payload?.senderId || null;
_routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, 0);
_routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, 0, targetGeneration);
}
function getTabVideoState(tabId) {
@@ -1623,12 +1656,21 @@ function getTabVideoState(tabId) {
});
}
async function getReadyTabVideoState(tabId) {
async function getReadyTabVideoState(tabId, expectedGeneration = targetActivationGeneration) {
let state = await getTabVideoState(tabId);
if (!state || state.error) {
await injectContentScript(tabId);
const activation = await reactivateCurrentTarget(tabId, { expectedGeneration });
if (activation?.status !== 'ok') {
return { error: 'Target tab changed before content script recovery completed' };
}
await new Promise(resolve => setTimeout(resolve, 250));
if (!isCurrentTargetIdentity(tabId, activation.generation)) {
return { error: 'Target tab changed before video state could be read' };
}
state = await getTabVideoState(tabId);
if (!isCurrentTargetIdentity(tabId, activation.generation)) {
return { error: 'Target tab changed while video state was being read' };
}
}
return state;
}
@@ -1845,18 +1887,80 @@ async function injectContentScript(tabId, { requestHostAccess = true } = {}) {
let pendingTargetMutation = Promise.resolve();
const PENDING_TARGET_KEYS = [
'pendingTargetTabId',
'pendingTargetTabTitle',
'pendingTargetHost',
'pendingTargetOriginPattern',
'pendingTargetRequestId'
];
function createPendingTargetRequestId() {
return globalThis.crypto?.randomUUID?.()
|| `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function emptyPendingTargetState() {
return {
pendingTargetTabId: null,
pendingTargetTabTitle: null,
pendingTargetHost: null,
pendingTargetOriginPattern: null,
pendingTargetRequestId: null
};
}
function mutatePendingTarget(operation) {
const result = pendingTargetMutation.catch(() => {}).then(operation);
pendingTargetMutation = result.catch(() => {});
return result;
}
async function rememberPendingTarget(tabId, tabTitle, error) {
async function readPendingTarget() {
return mutatePendingTarget(async () => {
const previous = await chrome.storage.session.get([
'pendingTargetTabId',
'pendingTargetOriginPattern'
]);
const stored = await chrome.storage.session.get(PENDING_TARGET_KEYS);
const tabId = normalizeTabId(stored.pendingTargetTabId);
const originPattern = typeof stored.pendingTargetOriginPattern === 'string'
&& stored.pendingTargetOriginPattern.length > 0
? stored.pendingTargetOriginPattern
: null;
if (tabId === null || originPattern === null) {
if (Object.values(stored).some(value => value !== null && value !== undefined)) {
if (tabId !== null) {
await removeTabHostAccessRequest(chrome, tabId, originPattern);
}
await chrome.storage.session.set(emptyPendingTargetState());
}
return null;
}
const requestId = typeof stored.pendingTargetRequestId === 'string'
&& stored.pendingTargetRequestId.length > 0
? stored.pendingTargetRequestId
: createPendingTargetRequestId();
if (stored.pendingTargetRequestId !== requestId) {
await chrome.storage.session.set({ pendingTargetRequestId: requestId });
}
return {
tabId,
tabTitle: typeof stored.pendingTargetTabTitle === 'string'
? stored.pendingTargetTabTitle
: null,
host: typeof stored.pendingTargetHost === 'string'
? stored.pendingTargetHost
: null,
originPattern,
requestId
};
});
}
async function rememberPendingTarget(tabId, tabTitle, error, expectedGeneration) {
return mutatePendingTarget(async () => {
if (targetActivationGeneration !== expectedGeneration) return null;
const previous = await chrome.storage.session.get(PENDING_TARGET_KEYS);
const previousTabId = normalizeTabId(previous.pendingTargetTabId);
const nextOriginPattern = error?.originPattern || null;
if (previousTabId !== null && (
@@ -1869,22 +1973,48 @@ async function rememberPendingTarget(tabId, tabTitle, error) {
previous.pendingTargetOriginPattern || null
);
}
if (targetActivationGeneration !== expectedGeneration) return null;
const requestId = previousTabId === tabId
&& previous.pendingTargetOriginPattern === nextOriginPattern
&& typeof previous.pendingTargetRequestId === 'string'
&& previous.pendingTargetRequestId.length > 0
? previous.pendingTargetRequestId
: createPendingTargetRequestId();
await chrome.storage.session.set({
pendingTargetTabId: tabId,
pendingTargetTabTitle: typeof tabTitle === 'string' ? tabTitle : null,
pendingTargetHost: error?.host || null,
pendingTargetOriginPattern: nextOriginPattern
pendingTargetOriginPattern: nextOriginPattern,
pendingTargetRequestId: requestId
});
if (targetActivationGeneration !== expectedGeneration) {
const current = await chrome.storage.session.get(PENDING_TARGET_KEYS);
if (current.pendingTargetRequestId === requestId) {
await removeTabHostAccessRequest(chrome, tabId, nextOriginPattern);
await chrome.storage.session.set(emptyPendingTargetState());
}
return null;
}
return {
tabId,
tabTitle: typeof tabTitle === 'string' ? tabTitle : null,
host: error?.host || null,
originPattern: nextOriginPattern,
requestId
};
});
}
async function clearPendingTarget() {
async function clearPendingTarget({ expectedRequestId = null, expectedTabId = null } = {}) {
return mutatePendingTarget(async () => {
const pending = await chrome.storage.session.get([
'pendingTargetTabId',
'pendingTargetOriginPattern'
]);
const pending = await chrome.storage.session.get(PENDING_TARGET_KEYS);
const pendingTabId = normalizeTabId(pending.pendingTargetTabId);
if (expectedRequestId !== null && pending.pendingTargetRequestId !== expectedRequestId) {
return false;
}
if (expectedTabId !== null && pendingTabId !== normalizeTabId(expectedTabId)) {
return false;
}
if (pendingTabId !== null) {
await removeTabHostAccessRequest(
chrome,
@@ -1892,112 +2022,200 @@ async function clearPendingTarget() {
pending.pendingTargetOriginPattern || null
);
}
await chrome.storage.session.set({
pendingTargetTabId: null,
pendingTargetTabTitle: null,
pendingTargetHost: null,
pendingTargetOriginPattern: null
});
await chrome.storage.session.set(emptyPendingTargetState());
return true;
});
}
async function activateTargetTab(tabId, tabTitle, { requestHostAccess = true } = {}) {
async function activateTargetTab(tabId, tabTitle, {
requestHostAccess = true,
expectedGeneration = null,
expectedCurrentTabId = null
} = {}) {
const selectedTabId = normalizeTabId(tabId);
if (selectedTabId === null) {
return { status: 'invalid_tab' };
}
if (expectedGeneration !== null && targetActivationGeneration !== expectedGeneration) {
return { status: 'superseded' };
}
if (expectedCurrentTabId !== null
&& normalizeTabId(currentTabId) !== normalizeTabId(expectedCurrentTabId)) {
return { status: 'superseded' };
}
completeForceSyncBeforeTargetChange(selectedTabId);
const activationGeneration = ++targetActivationGeneration;
activeTargetActivation = { generation: activationGeneration, tabId: selectedTabId };
const previousTabId = normalizeTabId(currentTabId);
try {
await injectContentScript(selectedTabId, { requestHostAccess });
} catch (error) {
try {
await injectContentScript(selectedTabId, { requestHostAccess });
} catch (error) {
if (activationGeneration !== targetActivationGeneration) {
if (error?.code === HOST_ACCESS_REQUIRED_STATUS
&& error.requestAdded === true
&& activeTargetActivation?.tabId !== selectedTabId) {
await removeTabHostAccessRequest(
chrome,
selectedTabId,
error.originPattern || null
);
}
return { status: 'superseded' };
}
currentTabId = null;
currentTabTitle = null;
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
if (previousTabId) {
resetAudioProcessingInTab(previousTabId);
}
await chrome.storage.session.set({
currentTabId: null,
currentTabTitle: null,
roomIdleSince,
lastContentHeartbeatAt: null
});
if (activationGeneration !== targetActivationGeneration) {
return { status: 'superseded' };
}
updateBadgeStatus();
if (error?.code === HOST_ACCESS_REQUIRED_STATUS) {
const pending = await rememberPendingTarget(
selectedTabId,
tabTitle,
error,
activationGeneration
);
if (!pending || activationGeneration !== targetActivationGeneration) {
return { status: 'superseded' };
}
chrome.runtime.sendMessage({
type: 'TARGET_TAB_ACCESS_REQUIRED',
requestId: pending.requestId,
...injectionFailureResponse(error)
}).catch(() => {});
} else {
await clearPendingTarget();
if (activationGeneration !== targetActivationGeneration) {
return { status: 'superseded' };
}
}
throw error;
}
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
return { status: 'superseded' };
}
currentTabId = null;
currentTabTitle = null;
await applyAudioSettingsToTab(selectedTabId);
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
return { status: 'superseded' };
}
await removeTabHostAccessRequest(chrome, selectedTabId);
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
return { status: 'superseded' };
}
await clearPendingTarget();
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
return { status: 'superseded' };
}
currentTabId = selectedTabId;
currentTabTitle = typeof tabTitle === 'string' ? tabTitle : null;
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
if (previousTabId) {
if (previousTabId && previousTabId !== selectedTabId) {
resetAudioProcessingInTab(previousTabId);
}
await chrome.storage.session.set({
currentTabId: null,
currentTabTitle: null,
currentTabId,
currentTabTitle,
roomIdleSince,
lastContentHeartbeatAt: null
lastContentHeartbeatAt
});
if (activationGeneration !== targetActivationGeneration) {
return { status: 'superseded' };
}
updateBadgeStatus();
if (error?.code === HOST_ACCESS_REQUIRED_STATUS) {
await rememberPendingTarget(selectedTabId, tabTitle, error);
chrome.runtime.sendMessage({
type: 'TARGET_TAB_ACCESS_REQUIRED',
...injectionFailureResponse(error)
}).catch(() => {});
} else {
await clearPendingTarget();
return { status: 'ok', tabId: selectedTabId, generation: activationGeneration };
} finally {
if (activeTargetActivation?.generation === activationGeneration) {
activeTargetActivation = null;
}
throw error;
}
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
return { status: 'superseded' };
}
await applyAudioSettingsToTab(selectedTabId);
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
return { status: 'superseded' };
}
await clearPendingTarget();
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
return { status: 'superseded' };
}
currentTabId = selectedTabId;
currentTabTitle = typeof tabTitle === 'string' ? tabTitle : null;
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
if (previousTabId && previousTabId !== selectedTabId) {
resetAudioProcessingInTab(previousTabId);
}
await chrome.storage.session.set({
currentTabId,
currentTabTitle,
roomIdleSince,
lastContentHeartbeatAt
});
updateBadgeStatus();
return { status: 'ok', tabId: selectedTabId };
}
async function retryPendingTarget() {
const pending = await chrome.storage.session.get([
'pendingTargetTabId',
'pendingTargetTabTitle'
]);
const tabId = normalizeTabId(pending.pendingTargetTabId);
if (tabId === null) return null;
async function reactivateCurrentTarget(tabId, { expectedGeneration = targetActivationGeneration } = {}) {
const selectedTabId = normalizeTabId(tabId);
if (selectedTabId === null || !isCurrentTargetIdentity(selectedTabId, expectedGeneration)) {
return { status: 'superseded' };
}
if (activeTargetActivation && activeTargetActivation.tabId !== selectedTabId) {
return { status: 'superseded' };
}
return activateTargetTab(selectedTabId, currentTabTitle, {
expectedGeneration,
expectedCurrentTabId: selectedTabId
});
}
async function retryPendingTarget({ expectedRequestId = null, requireGrantedAccess = false } = {}) {
let pending = await readPendingTarget();
if (!pending || (expectedRequestId !== null && pending.requestId !== expectedRequestId)) {
return null;
}
if (activeTargetActivation && activeTargetActivation.tabId !== pending.tabId) {
return { status: 'superseded' };
}
if (requireGrantedAccess) {
let access;
try {
access = await inspectTabHostAccess(chrome, pending.tabId);
} catch {
await clearPendingTarget({
expectedRequestId: pending.requestId,
expectedTabId: pending.tabId
});
return { status: 'invalid_tab' };
}
if (access.granted !== true || access.originPattern !== pending.originPattern) {
return { status: 'permission_not_granted' };
}
pending = await readPendingTarget();
if (!pending || pending.requestId !== expectedRequestId) {
return { status: 'superseded' };
}
}
try {
const response = await activateTargetTab(tabId, pending.pendingTargetTabTitle, {
requestHostAccess: false
const expectedGeneration = targetActivationGeneration;
const response = await activateTargetTab(pending.tabId, pending.tabTitle, {
requestHostAccess: false,
expectedGeneration
});
if (response.status === 'ok') {
addLog(`Website access granted; selected tab ${tabId}`, 'success');
chrome.runtime.sendMessage({ type: 'TARGET_TAB_READY', tabId }).catch(() => {});
addLog(`Website access granted; selected tab ${pending.tabId}`, 'success');
chrome.runtime.sendMessage({
type: 'TARGET_TAB_READY',
tabId: pending.tabId,
requestId: pending.requestId
}).catch(() => {});
}
return response;
} catch (error) {
if (error?.code !== HOST_ACCESS_REQUIRED_STATUS) {
await clearPendingTarget();
await clearPendingTarget({
expectedRequestId: pending.requestId,
expectedTabId: pending.tabId
});
addLog(`Pending tab activation failed: ${error.message}`, 'warn');
}
return injectionFailureResponse(error);
@@ -2005,10 +2223,21 @@ async function retryPendingTarget() {
}
if (chrome.permissions?.onAdded?.addListener) {
chrome.permissions.onAdded.addListener(() => {
ensureState()
.then(() => retryPendingTarget())
.catch(error => addLog(`Website access retry failed: ${error.message}`, 'warn'));
chrome.permissions.onAdded.addListener((addedPermissions) => {
ensureState().then(async () => {
const pending = await readPendingTarget();
if (!pending) return;
const addedOrigins = Array.isArray(addedPermissions?.origins)
? addedPermissions.origins
: [];
if (!addedOrigins.includes(pending.originPattern) && !addedOrigins.includes('<all_urls>')) {
return;
}
await retryPendingTarget({
expectedRequestId: pending.requestId,
requireGrantedAccess: true
});
}).catch(error => addLog(`Website access retry failed: ${error.message}`, 'warn'));
});
}
@@ -2017,19 +2246,30 @@ if (chrome.tabs?.onRemoved?.addListener) {
ensureState().then(async () => {
const tabId = normalizeTabId(removedTabId);
if (tabId === null) return;
const pending = await chrome.storage.session.get('pendingTargetTabId');
const pending = await readPendingTarget();
const isCurrent = normalizeTabId(currentTabId) === tabId;
const isPending = normalizeTabId(pending.pendingTargetTabId) === tabId;
if (!isCurrent && !isPending) return;
const isPending = pending?.tabId === tabId;
const isActivating = activeTargetActivation?.tabId === tabId;
if (!isCurrent && !isPending && !isActivating) return;
targetActivationGeneration++;
const hasReplacementActivation = activeTargetActivation
&& activeTargetActivation.tabId !== tabId;
if (isCurrent) completeForceSyncBeforeTargetChange(null);
if (isActivating || (isCurrent && !hasReplacementActivation)) {
invalidateTargetActivations();
}
if (isCurrent) {
currentTabId = null;
currentTabTitle = null;
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
}
if (isPending) await clearPendingTarget();
if (isPending) {
await clearPendingTarget({
expectedRequestId: pending.requestId,
expectedTabId: tabId
});
}
await chrome.storage.session.set({
currentTabId,
currentTabTitle,
@@ -2038,11 +2278,44 @@ if (chrome.tabs?.onRemoved?.addListener) {
});
updateBadgeStatus();
chrome.runtime.sendMessage({ type: 'TARGET_TAB_CLEARED', tabId }).catch(() => {});
if (isCurrent) {
addLog('Target tab closed.', 'warn');
if (currentRoom) {
const roomAtClose = currentRoom;
getSettings().then(settings => {
if (currentRoom !== roomAtClose) return;
emit(EVENTS.PEER_STATUS, {
peerId,
playbackState: 'paused',
currentTime: null,
mediaTitle: null,
username: settings.username,
tabTitle: null
});
const me = currentRoom?.peers?.find(p => (p.peerId || p) === peerId);
if (me && typeof me === 'object') {
me.playbackState = 'paused';
me.currentTime = null;
me.mediaTitle = null;
me.tabTitle = null;
me.lastHeartbeat = Date.now();
if (storageInitialized) {
chrome.storage.session.set({ currentRoom });
}
chrome.runtime.sendMessage({
type: 'PEER_UPDATE',
peers: currentRoom.peers
}).catch(() => {});
}
}).catch(() => {});
}
}
}).catch(error => addLog(`Closed target-tab cleanup failed: ${error.message}`, 'warn'));
});
}
function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries) {
function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries, targetGeneration) {
if (!isCurrentTargetIdentity(tabId, targetGeneration)) return;
chrome.tabs.sendMessage(tabId, {
type: 'SERVER_COMMAND',
action,
@@ -2050,21 +2323,30 @@ function _routeToContentInternal(tabId, action, payload, actionTimestamp, comman
actionTimestamp,
commandSenderId
}).catch(err => {
if (!isCurrentTargetIdentity(tabId, targetGeneration)) return;
if (retries >= 3) {
addLog(`Content Script not responding in tab ${tabId} after ${retries} retries`, 'warn');
clearTargetTabForIdle();
clearTargetTabForIdle(tabId, targetGeneration);
return;
}
if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) {
activateTargetTab(tabId, currentTabTitle).then(response => {
reactivateCurrentTarget(tabId, { expectedGeneration: targetGeneration }).then(response => {
if (response?.status !== 'ok') return;
setTimeout(() => _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries + 1), 500);
setTimeout(() => _routeToContentInternal(
tabId,
action,
payload,
actionTimestamp,
commandSenderId,
retries + 1,
response.generation
), 500);
}).catch(_err => {
addLog(`Auto-reinject failed for tab ${tabId}`, 'warn');
});
} else {
addLog(`Content Script not responding in tab ${tabId}`, 'warn');
clearTargetTabForIdle();
clearTargetTabForIdle(tabId, targetGeneration);
}
});
}
@@ -2216,11 +2498,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (message.retryPendingTarget === true) {
await retryPendingTarget();
}
const pendingTarget = await chrome.storage.session.get([
'pendingTargetTabId',
'pendingTargetHost',
'pendingTargetOriginPattern'
]);
const pendingTarget = await readPendingTarget();
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
const isReconnecting = !isConnected && reconnectAttempts > 0;
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
@@ -2232,9 +2510,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
peers: currentRoom ? currentRoom.peers : [],
lastActionState,
targetTabId: currentTabId,
pendingTargetTabId: pendingTarget.pendingTargetTabId || null,
pendingTargetHost: pendingTarget.pendingTargetHost || null,
pendingTargetOriginPattern: pendingTarget.pendingTargetOriginPattern || null,
pendingTargetTabId: pendingTarget?.tabId ?? null,
pendingTargetHost: pendingTarget?.host ?? null,
pendingTargetOriginPattern: pendingTarget?.originPattern ?? null,
pendingTargetRequestId: pendingTarget?.requestId ?? null,
episodeLobby: episodeLobby,
reconnectAttempts,
reconnectSlowMode: reconnectFailed,
@@ -2286,6 +2565,10 @@ 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)) {
sendResponse({ status: 'ignored_unselected_tab', target: null });
return;
}
// content.js resync: hand back the host's extrapolated current position.
sendResponse({ target: getHostSyncTarget() });
} else if (message.type === 'GET_HCM_STRINGS') {
@@ -2309,7 +2592,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 && currentTabId && currentTabId !== sender.tab.id) {
if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) {
sendResponse({ status: 'ignored_unselected_tab' });
return;
}
@@ -2320,6 +2603,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (storageInitialized) chrome.storage.session.set({ hcmDesynced });
sendResponse({ status: 'ok' });
} else if (message.type === 'LEAVE_ROOM') {
completeForceSyncBeforeTargetChange(null);
connectIntent = false;
reconnectFailed = false;
reconnectAttempts = 0;
@@ -2335,7 +2619,7 @@ 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();
targetActivationGeneration++;
invalidateTargetActivations();
currentTabId = null;
currentTabTitle = null;
roomIdleSince = null;
@@ -2475,6 +2759,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'error', message: err.message });
});
} else if (message.type === 'CONTENT_EVENT') {
if (!sender?.tab && message.expectedTabId !== undefined) {
const expectedTabId = normalizeTabId(message.expectedTabId);
if (expectedTabId === null || normalizeTabId(currentTabId) !== expectedTabId) {
sendResponse({ status: 'stale_target' });
return;
}
}
const processEvent = async () => {
// Host Control Mode (sender-side): a non-controller in host-only mode must
// not drive the room. Don't broadcast; hand the action back to content.js so
@@ -2618,6 +2909,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
});
}
} else if (message.type === 'FORCE_SYNC_ACK') {
if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) {
sendResponse({ status: 'ignored_unselected_tab' });
return;
}
if (isForceSyncInitiator) {
forceSyncAcks.add(peerId);
chrome.storage.session.set({ forceSyncAcks: Array.from(forceSyncAcks) });
@@ -2642,6 +2937,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
sendResponse({ status: 'ok' });
} else if (message.type === 'CMD_ACK') {
if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) {
sendResponse({ status: 'ignored_unselected_tab' });
return;
}
const commandSenderId = message.commandSenderId;
// Only ACK if the command sender is still a known peer in our room.
// If we've already seen their PEER_STATUS 'left', skip the ACK — it would
@@ -2711,7 +3010,17 @@ async function handleAsyncMessage(message, sender, sendResponse) {
return true;
}
activateTargetTab(tabId, currentTabTitle).then(response => {
const expectedCurrentTabId = normalizeTabId(message.expectedCurrentTabId);
if (expectedCurrentTabId === null
|| tabId !== expectedCurrentTabId
|| normalizeTabId(currentTabId) !== expectedCurrentTabId) {
sendResponse({ status: 'stale_target' });
return true;
}
reactivateCurrentTarget(tabId, {
expectedGeneration: targetActivationGeneration
}).then(response => {
sendResponse(response);
}).catch(err => {
addLog(`Failed to inject into tab: ${err.message}`, 'warn');
@@ -2721,7 +3030,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} else if (message.type === 'SET_TARGET_TAB') {
if (message.tabId === null || message.tabId === undefined || message.tabId === '') {
const previousTabId = currentTabId;
targetActivationGeneration++;
completeForceSyncBeforeTargetChange(null);
invalidateTargetActivations();
currentTabId = null;
currentTabTitle = null;
lastContentHeartbeatAt = null;
@@ -2741,6 +3051,12 @@ async function handleAsyncMessage(message, sender, sendResponse) {
try {
const response = await activateTargetTab(message.tabId, message.tabTitle);
if (response?.status === 'ok') {
chrome.runtime.sendMessage({
type: 'TARGET_TAB_READY',
tabId: response.tabId
}).catch(() => {});
}
sendResponse(response);
} catch (error) {
addLog(`Failed to select tab: ${error.message}`, 'warn');
@@ -2928,24 +3244,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
initTabManager({
getCurrentTabId: () => currentTabId,
setCurrentTabId: (val) => {
if (val !== currentTabId) targetActivationGeneration++;
currentTabId = val;
},
setCurrentTabTitle: (val) => { currentTabTitle = val; },
setLastContentHeartbeatAt: (val) => { lastContentHeartbeatAt = val; },
setRoomIdleSince: (val) => { roomIdleSince = val; },
getCurrentRoom: () => currentRoom,
getPeerId: () => peerId,
getStorageInitialized: () => storageInitialized,
updateBadgeStatus,
addLog,
getSettings,
emit,
applyAudioSettingsToTab,
injectContentScript,
ensureState,
EVENTS
reactivateCurrentTarget,
ensureState
});
// Initial Connect — only if user has an active room configuration
+33 -7
View File
@@ -12,16 +12,23 @@ export function normalizeTabId(value) {
return Number.isSafeInteger(tabId) ? tabId : null;
}
export function describeTabUrl(rawUrl) {
function browserSupportsPortMatchPatterns(chromeApi) {
// runtime.getBrowserInfo is a Firefox-only WebExtension API. Firefox does
// not accept ports in match patterns, while Chromium does.
return typeof chromeApi?.runtime?.getBrowserInfo !== 'function';
}
export function describeTabUrl(rawUrl, { includePort = true } = {}) {
if (typeof rawUrl !== 'string' || !rawUrl) return null;
try {
const url = new URL(rawUrl);
if (url.protocol === 'http:' || url.protocol === 'https:') {
const permissionHost = includePort ? url.host : url.hostname;
return {
url: rawUrl,
host: url.host,
originPattern: `${url.origin}/*`
originPattern: `${url.protocol}//${permissionHost}/*`
};
}
if (url.protocol === 'file:') {
@@ -40,7 +47,11 @@ export function describeTabUrl(rawUrl) {
export async function inspectTabHostAccess(chromeApi, tabId) {
const tab = await chromeApi.tabs.get(tabId);
const descriptor = describeTabUrl(tab?.pendingUrl || tab?.url || '');
// executeScript targets the committed document. pendingUrl may already point
// at another origin while tab.url is still the document being injected into.
const descriptor = describeTabUrl(tab?.url || tab?.pendingUrl || '', {
includePort: browserSupportsPortMatchPatterns(chromeApi)
});
if (!descriptor) {
return {
tab,
@@ -59,7 +70,8 @@ export async function inspectTabHostAccess(chromeApi, tabId) {
const granted = await callBooleanPermissionMethod(
chromeApi,
'contains',
{ origins: [descriptor.originPattern] }
{ origins: [descriptor.originPattern] },
{ timeoutMs: 1000 }
);
return {
tab,
@@ -73,20 +85,29 @@ export async function inspectTabHostAccess(chromeApi, tabId) {
}
}
function callBooleanPermissionMethod(chromeApi, methodName, request) {
function callBooleanPermissionMethod(chromeApi, methodName, request, { timeoutMs = null } = {}) {
const method = chromeApi.permissions?.[methodName];
if (typeof method !== 'function') return Promise.resolve(null);
return new Promise((resolve, reject) => {
let settled = false;
let timeout = null;
const clearSettlementTimeout = () => {
if (timeout !== null) {
clearTimeout(timeout);
timeout = null;
}
};
const finish = (value) => {
if (settled) return;
settled = true;
resolve(value === true);
clearSettlementTimeout();
resolve(value === true ? true : value === false ? false : null);
};
const fail = (error) => {
if (settled) return;
settled = true;
clearSettlementTimeout();
reject(error instanceof Error ? error : new Error(String(error || 'Permission request failed')));
};
const callback = (value) => {
@@ -98,6 +119,10 @@ function callBooleanPermissionMethod(chromeApi, methodName, request) {
finish(value);
};
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
timeout = setTimeout(() => finish(null), timeoutMs);
}
try {
const result = method.call(chromeApi.permissions, request, callback);
if (result && typeof result.then === 'function') {
@@ -120,7 +145,8 @@ export function requestOriginPermission(chromeApi, originPattern) {
return callBooleanPermissionMethod(
chromeApi,
'request',
{ origins: [originPattern] }
{ origins: [originPattern] },
{ timeoutMs: 60000 }
).catch(() => false);
}
+8 -72
View File
@@ -1,20 +1,7 @@
export function initTabManager({
getCurrentTabId,
setCurrentTabId,
setCurrentTabTitle,
setLastContentHeartbeatAt,
setRoomIdleSince,
getCurrentRoom,
getPeerId,
getStorageInitialized,
updateBadgeStatus,
addLog,
getSettings,
emit,
applyAudioSettingsToTab,
injectContentScript,
ensureState,
EVENTS
reactivateCurrentTarget,
ensureState
}) {
chrome.storage.onChanged.addListener(async (changes, area) => {
if (area !== 'local' || !changes.audioSettings) return;
@@ -28,65 +15,14 @@ export function initTabManager({
}).catch(() => {});
});
chrome.tabs.onRemoved.addListener(async (tabId) => {
await ensureState();
if (tabId === getCurrentTabId()) {
const wasInRoom = !!getCurrentRoom();
setCurrentTabId(null);
setCurrentTabTitle(null);
setLastContentHeartbeatAt(null);
const now = Date.now();
setRoomIdleSince(now);
chrome.storage.session.set({
currentTabId: null,
currentTabTitle: null,
roomIdleSince: now,
lastContentHeartbeatAt: null
});
updateBadgeStatus();
addLog('Target tab closed.', 'warn');
if (wasInRoom) {
const roomAtClose = getCurrentRoom();
getSettings().then(settings => {
if (getCurrentRoom() !== roomAtClose) return;
emit(EVENTS.PEER_STATUS, {
peerId: getPeerId(),
playbackState: 'paused',
currentTime: null,
mediaTitle: null,
username: settings.username,
tabTitle: null
});
const room = getCurrentRoom();
if (room && Array.isArray(room.peers)) {
const me = room.peers.find(p => (p.peerId || p) === getPeerId());
if (me && typeof me === 'object') {
me.playbackState = 'paused';
me.currentTime = null;
me.mediaTitle = null;
me.tabTitle = null;
me.lastHeartbeat = Date.now();
if (getStorageInitialized()) {
chrome.storage.session.set({ currentRoom: room });
}
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: room.peers }).catch(() => {});
}
}
}).catch(() => {});
}
}
});
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => {
await ensureState();
const curTabId = getCurrentTabId();
if (curTabId && tabId === parseInt(curTabId) && changeInfo.status === 'complete') {
injectContentScript(tabId)
.then(() => applyAudioSettingsToTab(tabId))
.catch(() => {});
const currentTabId = Number(getCurrentTabId());
if (Number.isInteger(currentTabId)
&& currentTabId > 0
&& tabId === currentTabId
&& changeInfo.status === 'complete') {
reactivateCurrentTarget(tabId).catch(() => {});
}
});
}
+103 -24
View File
@@ -3,7 +3,7 @@ import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js';
import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js';
import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js';
import { requestOriginPermission } from './host-access.js';
import { normalizeTabId, requestOriginPermission } from './host-access.js';
const elements = {
@@ -415,7 +415,11 @@ async function init() {
}
if (res.pendingTargetTabId) {
showSiteAccessNotice(res.pendingTargetHost, res.pendingTargetOriginPattern);
showSiteAccessNotice(
res.pendingTargetHost,
res.pendingTargetOriginPattern,
res.pendingTargetTabId
);
} else {
hideSiteAccessNotice();
}
@@ -477,6 +481,7 @@ let hcmGuestLocked = false;
let siteAccessBlocked = false;
let siteAccessHost = null;
let siteAccessOriginPattern = null;
let siteAccessTabId = null;
// Co-Host state mirrored for the peer-list renderer (promote/demote + role badges).
let hcmAmOwner = false;
@@ -557,15 +562,21 @@ function setRemoteControlsLocked(locked) {
if (elements.pauseBtn) elements.pauseBtn.textContent = getMessage('BTN_PAUSE') || 'Pause';
}
function showSiteAccessNotice(host, originPattern = null) {
function showSiteAccessNotice(host, originPattern = null, tabId = null) {
siteAccessBlocked = true;
siteAccessHost = host || siteAccessHost || 'website';
siteAccessOriginPattern = originPattern || siteAccessOriginPattern;
siteAccessHost = typeof host === 'string' && host.length > 0 ? host : 'website';
siteAccessOriginPattern = typeof originPattern === 'string' && originPattern.length > 0
? originPattern
: null;
siteAccessTabId = normalizeTabId(tabId);
if (elements.siteAccessMessage) {
elements.siteAccessMessage.textContent = getMessage('SITE_ACCESS_REQUIRED', {
host: siteAccessHost
});
}
if (elements.siteAccessRetry) {
elements.siteAccessRetry.disabled = !siteAccessOriginPattern || siteAccessTabId === null;
}
if (elements.siteAccessNotice) elements.siteAccessNotice.style.display = 'block';
setRemoteControlsLocked(hcmGuestLocked);
}
@@ -574,13 +585,15 @@ function hideSiteAccessNotice() {
siteAccessBlocked = false;
siteAccessHost = null;
siteAccessOriginPattern = null;
siteAccessTabId = null;
if (elements.siteAccessRetry) elements.siteAccessRetry.disabled = false;
if (elements.siteAccessNotice) elements.siteAccessNotice.style.display = 'none';
setRemoteControlsLocked(hcmGuestLocked);
}
function handleTargetTabResponse(response) {
if (response?.status === 'host_permission_required') {
showSiteAccessNotice(response.host, response.originPattern);
showSiteAccessNotice(response.host, response.originPattern, response.tabId);
return false;
}
if (response?.status === 'ok') {
@@ -605,6 +618,32 @@ function selectTargetTab(tabId, tabTitle) {
});
}
async function refreshTargetAccessState() {
const status = await new Promise(resolve => {
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, response => {
if (chrome.runtime.lastError) {
resolve(null);
return;
}
resolve(response || null);
});
});
if (!status) return;
if (status.pendingTargetTabId) {
showSiteAccessNotice(
status.pendingTargetHost,
status.pendingTargetOriginPattern,
status.pendingTargetTabId
);
} else {
hideSiteAccessNotice();
}
await populateTabs(
status.peers,
status.targetTabId ?? status.pendingTargetTabId ?? null
);
}
if (elements.hostControlToggle) {
elements.hostControlToggle.addEventListener('change', () => {
const mode = elements.hostControlToggle.checked ? 'host-only' : 'everyone';
@@ -1537,7 +1576,11 @@ if (elements.langSelector) {
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
if (res.pendingTargetTabId) {
showSiteAccessNotice(res.pendingTargetHost, res.pendingTargetOriginPattern);
showSiteAccessNotice(
res.pendingTargetHost,
res.pendingTargetOriginPattern,
res.pendingTargetTabId
);
} else {
hideSiteAccessNotice();
}
@@ -1831,26 +1874,29 @@ elements.targetTab.addEventListener('change', async () => {
if (elements.siteAccessRetry) {
elements.siteAccessRetry.addEventListener('click', async () => {
const val = elements.targetTab.value;
const tabId = val ? parseInt(val) : null;
const tabId = normalizeTabId(elements.targetTab.value);
const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.dataset.originalTitle || null;
if (!tabId) {
if (tabId === null || siteAccessTabId !== tabId || !siteAccessOriginPattern) {
showToast(getMessage('ERR_SELECT_VIDEO'), 'warning');
return;
}
const requestedOriginPattern = siteAccessOriginPattern;
const requestedTabId = siteAccessTabId;
elements.siteAccessRetry.disabled = true;
elements.targetTab.disabled = true;
try {
await requestOriginPermission(chrome, requestedOriginPattern);
const selectedTabId = parseInt(elements.targetTab.value);
const granted = await requestOriginPermission(chrome, requestedOriginPattern);
if (granted !== true) return;
const selectedTabId = normalizeTabId(elements.targetTab.value);
// permissions.onAdded may already have completed activation, or a
// newer selection may have replaced this request while it was open.
if (!siteAccessBlocked || selectedTabId !== tabId
|| siteAccessTabId !== requestedTabId
|| siteAccessOriginPattern !== requestedOriginPattern) return;
await selectTargetTab(tabId, tabTitle);
} finally {
elements.siteAccessRetry.disabled = false;
elements.siteAccessRetry.disabled = siteAccessBlocked
&& (!siteAccessOriginPattern || siteAccessTabId === null);
elements.targetTab.disabled = false;
}
});
@@ -1868,6 +1914,11 @@ elements.forceSyncBtn.addEventListener('click', async () => {
elements.forceSyncBtn.disabled = false;
return;
}
const tabId = normalizeTabId(status.targetTabId);
if (tabId === null) {
elements.forceSyncBtn.disabled = false;
return;
}
const mode = elements.forceSyncMode.value;
let targetTime = null;
@@ -1909,9 +1960,9 @@ elements.forceSyncBtn.addEventListener('click', async () => {
}
};
forceSyncResetTimer = setTimeout(forceSyncReset, syncTimeoutMs);
const tabId = parseInt(status.targetTabId);
const failForceSyncTime = () => {
if (forceSyncDone) return;
if (forceSyncResetTimer) { clearTimeout(forceSyncResetTimer); forceSyncResetTimer = null; }
showError(getMessage('ERR_NO_VIDEO_TAB'));
forceSyncDone = true;
@@ -1919,7 +1970,17 @@ elements.forceSyncBtn.addEventListener('click', async () => {
elements.forceSyncBtn.textContent = originalText;
};
const sendForceSync = (time) => {
const targetIsStillCurrent = () => new Promise(resolve => {
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, currentStatus => {
if (chrome.runtime.lastError) {
resolve(false);
return;
}
resolve(normalizeTabId(currentStatus?.targetTabId) === tabId);
});
});
const sendForceSync = async (time) => {
if (time === null || time === undefined) {
failForceSyncTime();
return;
@@ -1929,15 +1990,28 @@ elements.forceSyncBtn.addEventListener('click', async () => {
failForceSyncTime();
return;
}
if (!(await targetIsStillCurrent())) {
failForceSyncTime();
return;
}
chrome.runtime.sendMessage({
type: 'CONTENT_EVENT',
action: EVENTS.FORCE_SYNC_PREPARE,
expectedTabId: tabId,
payload: { targetTime: target }
}, response => {
if (chrome.runtime.lastError || response?.status === 'stale_target') {
failForceSyncTime();
}
});
};
if (mode === 'jump-to-me') {
const retryQueryTime = () => {
const retryQueryTime = async () => {
if (!(await targetIsStillCurrent())) {
failForceSyncTime();
return;
}
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => {
if (chrome.runtime.lastError || !retryResponse || !Number.isFinite(retryResponse.currentTime)) {
failForceSyncTime();
@@ -1952,10 +2026,18 @@ elements.forceSyncBtn.addEventListener('click', async () => {
return;
}
if (chrome.runtime.lastError || !response) {
chrome.runtime.sendMessage({ type: 'INJECT_CONTENT_SCRIPT', tabId }, (injectResponse) => {
chrome.runtime.sendMessage({
type: 'INJECT_CONTENT_SCRIPT',
tabId,
expectedCurrentTabId: tabId
}, (injectResponse) => {
if (chrome.runtime.lastError || !injectResponse || injectResponse.status !== 'ok') {
if (injectResponse?.status === 'host_permission_required') {
showSiteAccessNotice(injectResponse.host, injectResponse.originPattern);
showSiteAccessNotice(
injectResponse.host,
injectResponse.originPattern,
injectResponse.tabId
);
}
failForceSyncTime();
return;
@@ -2251,14 +2333,11 @@ chrome.runtime.onMessage.addListener((msg) => {
});
}
} else if (msg.type === 'TARGET_TAB_READY') {
hideSiteAccessNotice();
populateTabs(null, msg.tabId);
refreshTargetAccessState().catch(() => {});
} else if (msg.type === 'TARGET_TAB_ACCESS_REQUIRED') {
showSiteAccessNotice(msg.host, msg.originPattern);
populateTabs(null, msg.tabId);
refreshTargetAccessState().catch(() => {});
} else if (msg.type === 'TARGET_TAB_CLEARED') {
hideSiteAccessNotice();
populateTabs();
refreshTargetAccessState().catch(() => {});
} else if (msg.type === 'PING_UPDATE') {
updatePingDisplay(msg.ping);
} else if (msg.type === 'HISTORY_UPDATE') {
+63
View File
@@ -34,6 +34,11 @@ assert.deepEqual(describeTabUrl('http://localhost:8096/web/'), {
host: 'localhost:8096',
originPattern: 'http://localhost:8096/*'
});
assert.deepEqual(describeTabUrl('http://localhost:8096/web/', { includePort: false }), {
url: 'http://localhost:8096/web/',
host: 'localhost:8096',
originPattern: 'http://localhost/*'
});
assert.equal(describeTabUrl('chrome://extensions/'), null);
assert.equal(describeTabUrl('not a url'), null);
@@ -54,6 +59,39 @@ assert.equal(access.granted, false);
assert.equal(access.host, 'video.example');
assert.deepEqual(containsRequest, { origins: ['https://video.example/*'] });
let firefoxContainsRequest = null;
const firefoxChrome = {
runtime: { getBrowserInfo: async () => ({ name: 'Firefox' }) },
tabs: {
get: async tabId => ({
id: tabId,
url: 'http://localhost:8096/web/',
pendingUrl: 'https://different.example/loading'
})
},
permissions: {
contains: async request => {
firefoxContainsRequest = request;
return false;
}
}
};
const firefoxAccess = await inspectTabHostAccess(firefoxChrome, 42);
assert.equal(firefoxAccess.host, 'localhost:8096');
assert.equal(firefoxAccess.originPattern, 'http://localhost/*');
assert.deepEqual(firefoxContainsRequest, { origins: ['http://localhost/*'] });
const unknownPermissionChrome = {
runtime: {},
tabs: {
get: async tabId => ({ id: tabId, url: 'https://video.example/watch' })
},
permissions: {
contains: (_request, callback) => { callback(undefined); }
}
};
assert.equal((await inspectTabHostAccess(unknownPermissionChrome, 42)).granted, null);
let requestedTabId = null;
const requestChrome = {
permissions: {
@@ -88,6 +126,7 @@ assert.equal(await requestOriginPermission({ permissions: {} }, 'https://video.e
const background = fs.readFileSync(path.join(cwd(), 'extension', 'background.js'), 'utf8');
const popup = fs.readFileSync(path.join(cwd(), 'extension', 'popup.js'), 'utf8');
const popupHtml = fs.readFileSync(path.join(cwd(), 'extension', 'popup.html'), 'utf8');
const tabManager = fs.readFileSync(path.join(cwd(), 'extension', 'modules', 'tab-manager.js'), 'utf8');
assert.match(background, /await activateTargetTab\(message\.tabId, message\.tabTitle\)/,
'SET_TARGET_TAB must await successful activation before acknowledging it');
@@ -97,6 +136,18 @@ assert.match(background, /retryPendingTarget\(\)/,
'pending target must resume after the user grants access');
assert.match(background, /activationGeneration !== targetActivationGeneration/,
'stale concurrent tab activations must not overwrite the newest selection');
assert.match(background, /pendingTargetRequestId/,
'pending access recovery must use an identity token');
assert.match(background, /addedOrigins\.includes\(pending\.originPattern\)/,
'unrelated permission grants must not activate a pending target');
assert.match(background, /isCurrentTargetIdentity\(tabId, targetGeneration\)/,
'stale content-routing retries must not reactivate an old target');
assert.match(background, /message\.expectedTabId/,
'popup playback events must be rejected after their target changes');
assert.match(background, /completeForceSyncBeforeTargetChange\(selectedTabId\)/,
'a target switch must finish an in-flight force sync on the old target');
assert.match(background, /FORCE_SYNC_ACK'[\s\S]*ignored_unselected_tab/,
'stale content scripts must not acknowledge force sync for a new target');
const activateTargetBody = background.slice(
background.indexOf('async function activateTargetTab'),
background.indexOf('async function retryPendingTarget')
@@ -111,6 +162,18 @@ assert.match(popup, /response\?\.status === 'host_permission_required'/,
'popup must render the structured host-access failure');
assert.match(popup, /requestOriginPermission\(chrome, requestedOriginPattern\)/,
'retry button must request withheld host access directly');
assert.match(popup, /expectedCurrentTabId: tabId/,
'manual reinjection must be tied to the selected target identity');
assert.match(popup, /expectedTabId: tabId/,
'force sync must be tied to the tab whose time was sampled');
assert.doesNotMatch(tabManager, /injectContentScript/,
'tab reload recovery must use the guarded background activation path');
assert.equal(
(background.match(/tabs\.onRemoved\.addListener/g) || []).length
+ (tabManager.match(/tabs\.onRemoved\.addListener/g) || []).length,
1,
'target-tab closure must have exactly one state owner'
);
assert.match(popupHtml, /id="siteAccessNotice"/,
'popup must contain a persistent site-access notice');