mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-21 16:36:44 +00:00
fix(extension): harden target frame recovery and switching
This commit is contained in:
+11
-3
@@ -19,6 +19,14 @@ recovery, failed tab switches and short connection interruptions.
|
|||||||
tab visible across popup close/reopen and retries activation after a dynamic
|
tab visible across popup close/reopen and retries activation after a dynamic
|
||||||
player frame changes, without restoring the removed `webNavigation`
|
player frame changes, without restoring the removed `webNavigation`
|
||||||
permission.
|
permission.
|
||||||
|
- **Extension: Target selection independent of video detection** — Exposes one
|
||||||
|
public `targetTabId`, keeps a selected no-video page active, and reports
|
||||||
|
activation readiness separately instead of treating a missing or ambiguous
|
||||||
|
player frame as a lost tab.
|
||||||
|
- **Extension: Defensive frame probing without `webNavigation`** — Isolates
|
||||||
|
rejected embedded-frame probes, retries known frame IDs individually, and
|
||||||
|
keeps visible-player discovery working when one child frame blocks an
|
||||||
|
`allFrames` sweep.
|
||||||
- **Extension: Chat visibility persistence** — Remembers the user's manual
|
- **Extension: Chat visibility persistence** — Remembers the user's manual
|
||||||
open/closed state across popup reopen, content refresh and reconnect cycles.
|
open/closed state across popup reopen, content refresh and reconnect cycles.
|
||||||
|
|
||||||
@@ -26,9 +34,9 @@ recovery, failed tab switches and short connection interruptions.
|
|||||||
- **Release gate** — Unit, server/WebSocket, locale, theme, lint, production
|
- **Release gate** — Unit, server/WebSocket, locale, theme, lint, production
|
||||||
dependency audit, Chrome/Firefox build, AMO validation and website build
|
dependency audit, Chrome/Firefox build, AMO validation and website build
|
||||||
pass locally.
|
pass locally.
|
||||||
- **Browser E2E** — 36 extension and player lifecycle scenarios pass locally,
|
- **Browser E2E** — 38 extension and player lifecycle scenarios pass locally,
|
||||||
including popup close/reopen persistence and repeated cross-origin frame
|
including popup close/reopen persistence, selectable no-video pages,
|
||||||
switching.
|
rejected all-frame recovery and repeated cross-origin frame switching.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+37
-28
@@ -11,7 +11,6 @@ import { createChatActivityStore } from './chat-activity.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 } from './host-access.js';
|
||||||
import {
|
import {
|
||||||
MEDIA_FRAME_ACCESS_REQUIRED,
|
MEDIA_FRAME_ACCESS_REQUIRED,
|
||||||
MEDIA_FRAME_AMBIGUOUS,
|
|
||||||
listMediaFrameScriptTargets,
|
listMediaFrameScriptTargets,
|
||||||
resolveMediaContentTarget
|
resolveMediaContentTarget
|
||||||
} from './media-frame-target.js';
|
} from './media-frame-target.js';
|
||||||
@@ -2249,6 +2248,7 @@ function deactivateMediaFrameMonitor() {
|
|||||||
async function deactivateMediaFrameMonitors(tabId, contentTarget = null) {
|
async function deactivateMediaFrameMonitors(tabId, contentTarget = null) {
|
||||||
const targets = uniqueScriptTargets([
|
const targets = uniqueScriptTargets([
|
||||||
contentTarget?.scriptTarget,
|
contentTarget?.scriptTarget,
|
||||||
|
...(contentTarget?.monitorTargets || []),
|
||||||
{ tabId },
|
{ tabId },
|
||||||
...listMediaFrameScriptTargets(tabId)
|
...listMediaFrameScriptTargets(tabId)
|
||||||
].filter(Boolean));
|
].filter(Boolean));
|
||||||
@@ -2279,7 +2279,7 @@ async function deactivateTargetTab(tabId, contentTarget = null, { deactivateMoni
|
|||||||
}
|
}
|
||||||
: null)
|
: null)
|
||||||
|| { frameId: 0, documentId: null };
|
|| { frameId: 0, documentId: null };
|
||||||
resetAudioProcessingInTab(normalizedTabId, target);
|
await resetAudioProcessingInTab(normalizedTabId, target);
|
||||||
await sendMessageToFrame(
|
await sendMessageToFrame(
|
||||||
normalizedTabId,
|
normalizedTabId,
|
||||||
target.frameId,
|
target.frameId,
|
||||||
@@ -2346,6 +2346,7 @@ async function injectMediaFrameMonitors(tabId, contentTarget) {
|
|||||||
// selected document explicitly so its lifecycle monitor is guaranteed.
|
// selected document explicitly so its lifecycle monitor is guaranteed.
|
||||||
const targets = uniqueScriptTargets([
|
const targets = uniqueScriptTargets([
|
||||||
contentTarget?.scriptTarget,
|
contentTarget?.scriptTarget,
|
||||||
|
...(contentTarget?.monitorTargets || []),
|
||||||
{ tabId },
|
{ tabId },
|
||||||
...listMediaFrameScriptTargets(tabId)
|
...listMediaFrameScriptTargets(tabId)
|
||||||
].filter(Boolean));
|
].filter(Boolean));
|
||||||
@@ -2403,8 +2404,7 @@ async function injectContentScript(tabId, {
|
|||||||
originPattern: error.originPattern
|
originPattern: error.originPattern
|
||||||
}, requestAdded, error);
|
}, requestAdded, error);
|
||||||
}
|
}
|
||||||
if (error?.code === MEDIA_FRAME_AMBIGUOUS) throw error;
|
throw error;
|
||||||
addLog(`Media frame probe fell back to the top frame: ${error.message}`, 'warn');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const scriptTarget = contentTarget.scriptTarget;
|
const scriptTarget = contentTarget.scriptTarget;
|
||||||
@@ -3238,27 +3238,27 @@ function leaveOldRoomIfSwitching(newRoomId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetAudioProcessingInTab(tabId, contentTarget = null) {
|
async function resetAudioProcessingInTab(tabId, contentTarget = null) {
|
||||||
if (!tabId) return;
|
const normalizedTabId = normalizeTabId(tabId);
|
||||||
|
if (normalizedTabId === null) return null;
|
||||||
if (contentTarget) {
|
if (contentTarget) {
|
||||||
sendMessageToFrame(
|
return sendMessageToFrame(
|
||||||
tabId,
|
normalizedTabId,
|
||||||
contentTarget.frameId,
|
contentTarget.frameId,
|
||||||
{ action: 'RESET_AUDIO_PROCESSING' },
|
{ action: 'RESET_AUDIO_PROCESSING' },
|
||||||
null,
|
null,
|
||||||
contentTarget.documentId
|
contentTarget.documentId
|
||||||
).catch(() => {});
|
).catch(() => null);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) {
|
if (normalizedTabId === normalizeTabId(currentTabId)) {
|
||||||
sendMessageToCurrentContent({ action: 'RESET_AUDIO_PROCESSING' }).catch(() => {});
|
return sendMessageToCurrentContent({ action: 'RESET_AUDIO_PROCESSING' }).catch(() => null);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
chrome.tabs.sendMessage(tabId, { action: 'RESET_AUDIO_PROCESSING' }).catch(() => {});
|
return chrome.tabs.sendMessage(normalizedTabId, { action: 'RESET_AUDIO_PROCESSING' }).catch(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyAudioSettingsToTab(tabId, contentTarget = null) {
|
async function applyAudioSettingsToTab(tabId, contentTarget = null) {
|
||||||
if (!tabId) return;
|
const normalizedTabId = normalizeTabId(tabId);
|
||||||
|
if (normalizedTabId === null) 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']);
|
||||||
const message = {
|
const message = {
|
||||||
@@ -3266,20 +3266,20 @@ async function applyAudioSettingsToTab(tabId, contentTarget = null) {
|
|||||||
settings: data.audioSettings
|
settings: data.audioSettings
|
||||||
};
|
};
|
||||||
if (contentTarget) {
|
if (contentTarget) {
|
||||||
sendMessageToFrame(
|
await sendMessageToFrame(
|
||||||
tabId,
|
normalizedTabId,
|
||||||
contentTarget.frameId,
|
contentTarget.frameId,
|
||||||
message,
|
message,
|
||||||
null,
|
null,
|
||||||
contentTarget.documentId
|
contentTarget.documentId
|
||||||
).catch(() => {});
|
).catch(() => null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) {
|
if (normalizedTabId === normalizeTabId(currentTabId)) {
|
||||||
sendMessageToCurrentContent(message).catch(() => {});
|
await sendMessageToCurrentContent(message).catch(() => null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
chrome.tabs.sendMessage(tabId, message).catch(() => {});
|
await chrome.tabs.sendMessage(normalizedTabId, message).catch(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Extension Message Listeners ---
|
// --- Extension Message Listeners ---
|
||||||
@@ -3379,21 +3379,30 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
|
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
|
||||||
// Distinguish the normal "not in a room" resting state from a real drop.
|
// Distinguish the normal "not in a room" resting state from a real drop.
|
||||||
if (status === 'disconnected' && !currentRoom && !connectIntent) status = 'idle';
|
if (status === 'disconnected' && !currentRoom && !connectIntent) status = 'idle';
|
||||||
const requestedTarget = normalizeTabId(requestedTargetTabId);
|
const targetTabId = normalizeTabId(requestedTargetTabId)
|
||||||
const activatingTarget = normalizeTabId(activeTargetActivation?.tabId);
|
?? normalizeTabId(activeTargetActivation?.tabId)
|
||||||
|
?? normalizeTabId(currentTabId);
|
||||||
|
const targetReady = targetTabId !== null
|
||||||
|
&& normalizeTabId(currentTabId) === targetTabId
|
||||||
|
&& !activeTargetActivation;
|
||||||
|
const targetActivationState = targetTabId === null
|
||||||
|
? 'none'
|
||||||
|
: targetReady
|
||||||
|
? 'ready'
|
||||||
|
: pendingTarget?.tabId === targetTabId
|
||||||
|
? 'access_required'
|
||||||
|
: 'activating';
|
||||||
sendResponse({
|
sendResponse({
|
||||||
status,
|
status,
|
||||||
peerId,
|
peerId,
|
||||||
peers: currentRoom ? currentRoom.peers : [],
|
peers: currentRoom ? currentRoom.peers : [],
|
||||||
lastActionState,
|
lastActionState,
|
||||||
targetTabId: currentTabId,
|
targetTabId,
|
||||||
targetFrameId: currentTargetFrameId,
|
targetFrameId: currentTargetFrameId,
|
||||||
targetDocumentId: currentTargetDocumentId,
|
targetDocumentId: currentTargetDocumentId,
|
||||||
targetHasVideo: currentTargetHasVideo,
|
targetHasVideo: currentTargetHasVideo,
|
||||||
selectedTargetTabId: requestedTarget ?? activatingTarget ?? normalizeTabId(currentTabId),
|
targetReady,
|
||||||
requestedTargetTabId: requestedTarget,
|
targetActivationState,
|
||||||
requestedTargetTitle,
|
|
||||||
activatingTargetTabId: activatingTarget,
|
|
||||||
pendingTargetTabId: pendingTarget?.tabId ?? null,
|
pendingTargetTabId: pendingTarget?.tabId ?? null,
|
||||||
pendingTargetHost: pendingTarget?.host ?? null,
|
pendingTargetHost: pendingTarget?.host ?? null,
|
||||||
pendingTargetOriginPattern: pendingTarget?.originPattern ?? null,
|
pendingTargetOriginPattern: pendingTarget?.originPattern ?? null,
|
||||||
|
|||||||
+15
-3
@@ -868,16 +868,27 @@
|
|||||||
|
|
||||||
runtimeMessage({ type: 'HCM_DESYNC_STATE', desynced: false }).catch(() => {});
|
runtimeMessage({ type: 'HCM_DESYNC_STATE', desynced: false }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resync to the host's current position (retries if host state not yet known).
|
// Resync to the host's current position (retries if host state not yet known).
|
||||||
|
|
||||||
hcmRequestHostSyncWithRetry();
|
hcmRequestHostSyncWithRetry();
|
||||||
|
|
||||||
|
reportLog('Host-only: resynced with the host', 'info');
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function hcmShowBadge() {
|
function hcmShowBadge() {
|
||||||
|
|
||||||
if (hcmBadgeHost) return;
|
if (hcmBadgeHost) return;
|
||||||
|
|
||||||
|
if (!document.body) {
|
||||||
|
|
||||||
|
// Body not ready yet (very early injection). Defer until DOMReady,
|
||||||
|
|
||||||
|
// otherwise the desynced user silently never sees the badge (L-4).
|
||||||
|
|
||||||
if (!hcmBadgePending) {
|
if (!hcmBadgePending) {
|
||||||
|
|
||||||
hcmBadgePending = true;
|
hcmBadgePending = true;
|
||||||
@@ -909,6 +920,7 @@
|
|||||||
|
|
||||||
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);
|
||||||
|
|
||||||
root.appendChild(b);
|
root.appendChild(b);
|
||||||
|
|
||||||
@@ -1238,7 +1250,7 @@
|
|||||||
bestRank = rank;
|
bestRank = rank;
|
||||||
|
|
||||||
}
|
}
|
||||||
for (const video of candidates) {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return best;
|
return best;
|
||||||
@@ -2062,7 +2074,7 @@
|
|||||||
|
|
||||||
if (action === EVENTS.FORCE_SYNC_PREPARE) {
|
if (action === EVENTS.FORCE_SYNC_PREPARE) {
|
||||||
|
|
||||||
// force-sync flow actually waits on. Skipping CMD_ACKs for PLAY/PAUSE/SEEK
|
runtimeMessage({ type: 'FORCE_SYNC_ACK' }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -2117,7 +2129,7 @@
|
|||||||
|
|
||||||
} else if (action === EVENTS.PAUSE) {
|
} else if (action === EVENTS.PAUSE) {
|
||||||
|
|
||||||
|
tryMediaAction(EVENTS.PAUSE);
|
||||||
|
|
||||||
runtimeMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
|
runtimeMessage({ type: 'CMD_ACK', actionTimestamp: message.actionTimestamp, commandSenderId: message.commandSenderId });
|
||||||
actionCompleted = true;
|
actionCompleted = true;
|
||||||
|
|||||||
@@ -190,6 +190,7 @@
|
|||||||
"NOTIF_FORCE_PREPARE": "eine erzwungene Synchronisation gestartet",
|
"NOTIF_FORCE_PREPARE": "eine erzwungene Synchronisation gestartet",
|
||||||
"NOTIF_FORCE_EXECUTE": "alle Teilnehmer synchronisiert",
|
"NOTIF_FORCE_EXECUTE": "alle Teilnehmer synchronisiert",
|
||||||
"DEBUG_NO_TAB": "Kein Ziel-Tab ausgewählt.",
|
"DEBUG_NO_TAB": "Kein Ziel-Tab ausgewählt.",
|
||||||
|
"DEBUG_TARGET_ACTIVATING": "Ziel-Tab ausgewählt; Video-Injection wird vorbereitet.",
|
||||||
"DEBUG_COMM_FAIL": "Kommunikation mit dem Tab-Video fehlgeschlagen.",
|
"DEBUG_COMM_FAIL": "Kommunikation mit dem Tab-Video fehlgeschlagen.",
|
||||||
"EMPTY_PEERS_TITLE": "Noch keine Teilnehmer",
|
"EMPTY_PEERS_TITLE": "Noch keine Teilnehmer",
|
||||||
"EMPTY_PEERS_HINT": "Teile deinen Einladungslink, um loszulegen",
|
"EMPTY_PEERS_HINT": "Teile deinen Einladungslink, um loszulegen",
|
||||||
|
|||||||
@@ -190,6 +190,7 @@
|
|||||||
"NOTIF_FORCE_PREPARE": "started force sync",
|
"NOTIF_FORCE_PREPARE": "started force sync",
|
||||||
"NOTIF_FORCE_EXECUTE": "synchronized everyone",
|
"NOTIF_FORCE_EXECUTE": "synchronized everyone",
|
||||||
"DEBUG_NO_TAB": "No target tab selected.",
|
"DEBUG_NO_TAB": "No target tab selected.",
|
||||||
|
"DEBUG_TARGET_ACTIVATING": "Target tab selected; preparing video injection.",
|
||||||
"DEBUG_COMM_FAIL": "Could not communicate with tab video.",
|
"DEBUG_COMM_FAIL": "Could not communicate with tab video.",
|
||||||
"EMPTY_PEERS_TITLE": "No peers yet",
|
"EMPTY_PEERS_TITLE": "No peers yet",
|
||||||
"EMPTY_PEERS_HINT": "Share your invite link to get started",
|
"EMPTY_PEERS_HINT": "Share your invite link to get started",
|
||||||
|
|||||||
@@ -190,6 +190,7 @@
|
|||||||
"NOTIF_FORCE_PREPARE": "ha iniciado una sincronización forzada",
|
"NOTIF_FORCE_PREPARE": "ha iniciado una sincronización forzada",
|
||||||
"NOTIF_FORCE_EXECUTE": "ha sincronizado a todos",
|
"NOTIF_FORCE_EXECUTE": "ha sincronizado a todos",
|
||||||
"DEBUG_NO_TAB": "No hay pestaña objetivo seleccionada.",
|
"DEBUG_NO_TAB": "No hay pestaña objetivo seleccionada.",
|
||||||
|
"DEBUG_TARGET_ACTIVATING": "Pestaña objetivo seleccionada; preparando la inyección de vídeo.",
|
||||||
"DEBUG_COMM_FAIL": "No se pudo comunicar con el video de la pestaña.",
|
"DEBUG_COMM_FAIL": "No se pudo comunicar con el video de la pestaña.",
|
||||||
"EMPTY_PEERS_TITLE": "Sin participantes aún",
|
"EMPTY_PEERS_TITLE": "Sin participantes aún",
|
||||||
"EMPTY_PEERS_HINT": "Comparte tu enlace de invitación para comenzar",
|
"EMPTY_PEERS_HINT": "Comparte tu enlace de invitación para comenzar",
|
||||||
|
|||||||
@@ -190,6 +190,7 @@
|
|||||||
"NOTIF_FORCE_PREPARE": "lancé une synchronisation forcée",
|
"NOTIF_FORCE_PREPARE": "lancé une synchronisation forcée",
|
||||||
"NOTIF_FORCE_EXECUTE": "synchronisé tout le monde",
|
"NOTIF_FORCE_EXECUTE": "synchronisé tout le monde",
|
||||||
"DEBUG_NO_TAB": "Aucun onglet cible sélectionné.",
|
"DEBUG_NO_TAB": "Aucun onglet cible sélectionné.",
|
||||||
|
"DEBUG_TARGET_ACTIVATING": "Onglet cible sélectionné ; préparation de l’injection vidéo.",
|
||||||
"DEBUG_COMM_FAIL": "Impossible de communiquer avec l'onglet vidéo.",
|
"DEBUG_COMM_FAIL": "Impossible de communiquer avec l'onglet vidéo.",
|
||||||
"EMPTY_PEERS_TITLE": "Aucun membre pour l'instant",
|
"EMPTY_PEERS_TITLE": "Aucun membre pour l'instant",
|
||||||
"EMPTY_PEERS_HINT": "Partagez votre lien d'invitation pour commencer",
|
"EMPTY_PEERS_HINT": "Partagez votre lien d'invitation pour commencer",
|
||||||
|
|||||||
@@ -190,6 +190,7 @@
|
|||||||
"NOTIF_FORCE_PREPARE": "ha avviato una sincronizzazione forzata",
|
"NOTIF_FORCE_PREPARE": "ha avviato una sincronizzazione forzata",
|
||||||
"NOTIF_FORCE_EXECUTE": "ha sincronizzato tutti",
|
"NOTIF_FORCE_EXECUTE": "ha sincronizzato tutti",
|
||||||
"DEBUG_NO_TAB": "Nessuna scheda selezionata.",
|
"DEBUG_NO_TAB": "Nessuna scheda selezionata.",
|
||||||
|
"DEBUG_TARGET_ACTIVATING": "Scheda target selezionata; preparazione dell’iniezione video.",
|
||||||
"DEBUG_COMM_FAIL": "Errore di comunicazione con il video.",
|
"DEBUG_COMM_FAIL": "Errore di comunicazione con il video.",
|
||||||
"EMPTY_PEERS_TITLE": "Nessun partecipante",
|
"EMPTY_PEERS_TITLE": "Nessun partecipante",
|
||||||
"EMPTY_PEERS_HINT": "Condividi il tuo link per iniziare",
|
"EMPTY_PEERS_HINT": "Condividi il tuo link per iniziare",
|
||||||
|
|||||||
@@ -190,6 +190,7 @@
|
|||||||
"NOTIF_FORCE_PREPARE": "強制同期を開始しました",
|
"NOTIF_FORCE_PREPARE": "強制同期を開始しました",
|
||||||
"NOTIF_FORCE_EXECUTE": "全員を同期しました",
|
"NOTIF_FORCE_EXECUTE": "全員を同期しました",
|
||||||
"DEBUG_NO_TAB": "対象のタブが選択されていません。",
|
"DEBUG_NO_TAB": "対象のタブが選択されていません。",
|
||||||
|
"DEBUG_TARGET_ACTIVATING": "対象のタブを選択しました。動画スクリプトを準備しています。",
|
||||||
"DEBUG_COMM_FAIL": "タブのビデオと通信できませんでした。",
|
"DEBUG_COMM_FAIL": "タブのビデオと通信できませんでした。",
|
||||||
"EMPTY_PEERS_TITLE": "メンバーはまだいません",
|
"EMPTY_PEERS_TITLE": "メンバーはまだいません",
|
||||||
"EMPTY_PEERS_HINT": "招待リンクを共有して始めましょう",
|
"EMPTY_PEERS_HINT": "招待リンクを共有して始めましょう",
|
||||||
|
|||||||
@@ -190,6 +190,7 @@
|
|||||||
"NOTIF_FORCE_PREPARE": "강제 동기화를 시작했습니다",
|
"NOTIF_FORCE_PREPARE": "강제 동기화를 시작했습니다",
|
||||||
"NOTIF_FORCE_EXECUTE": "모든 사용자를 동기화했습니다",
|
"NOTIF_FORCE_EXECUTE": "모든 사용자를 동기화했습니다",
|
||||||
"DEBUG_NO_TAB": "대상 탭이 선택되지 않았습니다.",
|
"DEBUG_NO_TAB": "대상 탭이 선택되지 않았습니다.",
|
||||||
|
"DEBUG_TARGET_ACTIVATING": "대상 탭이 선택되었습니다. 동영상 주입을 준비하는 중입니다.",
|
||||||
"DEBUG_COMM_FAIL": "탭 비디오와 통신할 수 없습니다.",
|
"DEBUG_COMM_FAIL": "탭 비디오와 통신할 수 없습니다.",
|
||||||
"EMPTY_PEERS_TITLE": "참여자 없음",
|
"EMPTY_PEERS_TITLE": "참여자 없음",
|
||||||
"EMPTY_PEERS_HINT": "시작하려면 초대 링크를 공유하세요",
|
"EMPTY_PEERS_HINT": "시작하려면 초대 링크를 공유하세요",
|
||||||
|
|||||||
@@ -190,6 +190,7 @@
|
|||||||
"NOTIF_FORCE_PREPARE": "is een geforceerde sync gestart",
|
"NOTIF_FORCE_PREPARE": "is een geforceerde sync gestart",
|
||||||
"NOTIF_FORCE_EXECUTE": "heeft iedereen gesynchroniseerd",
|
"NOTIF_FORCE_EXECUTE": "heeft iedereen gesynchroniseerd",
|
||||||
"DEBUG_NO_TAB": "Geen doeltabblad geselecteerd.",
|
"DEBUG_NO_TAB": "Geen doeltabblad geselecteerd.",
|
||||||
|
"DEBUG_TARGET_ACTIVATING": "Doeltabblad geselecteerd; video-injectie wordt voorbereid.",
|
||||||
"DEBUG_COMM_FAIL": "Kon niet communiceren met de videotab.",
|
"DEBUG_COMM_FAIL": "Kon niet communiceren met de videotab.",
|
||||||
"EMPTY_PEERS_TITLE": "Nog geen deelnemers",
|
"EMPTY_PEERS_TITLE": "Nog geen deelnemers",
|
||||||
"EMPTY_PEERS_HINT": "Deel uw uitnodigingslink om te beginnen",
|
"EMPTY_PEERS_HINT": "Deel uw uitnodigingslink om te beginnen",
|
||||||
|
|||||||
@@ -190,6 +190,7 @@
|
|||||||
"NOTIF_FORCE_PREPARE": "wymusił synchronizację",
|
"NOTIF_FORCE_PREPARE": "wymusił synchronizację",
|
||||||
"NOTIF_FORCE_EXECUTE": "zsynchronizował wszystkich",
|
"NOTIF_FORCE_EXECUTE": "zsynchronizował wszystkich",
|
||||||
"DEBUG_NO_TAB": "Nie wybrano karty docelowej.",
|
"DEBUG_NO_TAB": "Nie wybrano karty docelowej.",
|
||||||
|
"DEBUG_TARGET_ACTIVATING": "Wybrano kartę docelową; przygotowywanie wstrzyknięcia wideo.",
|
||||||
"DEBUG_COMM_FAIL": "Nie można skomunikować się z wideo w karcie.",
|
"DEBUG_COMM_FAIL": "Nie można skomunikować się z wideo w karcie.",
|
||||||
"EMPTY_PEERS_TITLE": "Brak uczestników",
|
"EMPTY_PEERS_TITLE": "Brak uczestników",
|
||||||
"EMPTY_PEERS_HINT": "Udostępnij link zaproszenia, aby rozpocząć",
|
"EMPTY_PEERS_HINT": "Udostępnij link zaproszenia, aby rozpocząć",
|
||||||
|
|||||||
@@ -190,6 +190,7 @@
|
|||||||
"NOTIF_FORCE_PREPARE": "iniciou uma sincronização forçada",
|
"NOTIF_FORCE_PREPARE": "iniciou uma sincronização forçada",
|
||||||
"NOTIF_FORCE_EXECUTE": "sincronizou todos",
|
"NOTIF_FORCE_EXECUTE": "sincronizou todos",
|
||||||
"DEBUG_NO_TAB": "Nenhuma aba selecionada.",
|
"DEBUG_NO_TAB": "Nenhuma aba selecionada.",
|
||||||
|
"DEBUG_TARGET_ACTIVATING": "Aba de destino selecionada; preparando a injeção de vídeo.",
|
||||||
"DEBUG_COMM_FAIL": "Erro ao se comunicar com o vídeo.",
|
"DEBUG_COMM_FAIL": "Erro ao se comunicar com o vídeo.",
|
||||||
"EMPTY_PEERS_TITLE": "Nenhum participante",
|
"EMPTY_PEERS_TITLE": "Nenhum participante",
|
||||||
"EMPTY_PEERS_HINT": "Compartilhe seu link de convite para começar",
|
"EMPTY_PEERS_HINT": "Compartilhe seu link de convite para começar",
|
||||||
|
|||||||
@@ -190,6 +190,7 @@
|
|||||||
"NOTIF_FORCE_PREPARE": "iniciou uma sincronização forçada",
|
"NOTIF_FORCE_PREPARE": "iniciou uma sincronização forçada",
|
||||||
"NOTIF_FORCE_EXECUTE": "sincronizou todos",
|
"NOTIF_FORCE_EXECUTE": "sincronizou todos",
|
||||||
"DEBUG_NO_TAB": "Nenhum separador selecionado.",
|
"DEBUG_NO_TAB": "Nenhum separador selecionado.",
|
||||||
|
"DEBUG_TARGET_ACTIVATING": "Separador de destino selecionado; a preparar a injeção de vídeo.",
|
||||||
"DEBUG_COMM_FAIL": "Erro ao comunicar com o vídeo.",
|
"DEBUG_COMM_FAIL": "Erro ao comunicar com o vídeo.",
|
||||||
"EMPTY_PEERS_TITLE": "Nenhum participante",
|
"EMPTY_PEERS_TITLE": "Nenhum participante",
|
||||||
"EMPTY_PEERS_HINT": "Partilhe o seu link de convite para começar",
|
"EMPTY_PEERS_HINT": "Partilhe o seu link de convite para começar",
|
||||||
|
|||||||
@@ -190,6 +190,7 @@
|
|||||||
"NOTIF_FORCE_PREPARE": "запустил принудительный синхрон",
|
"NOTIF_FORCE_PREPARE": "запустил принудительный синхрон",
|
||||||
"NOTIF_FORCE_EXECUTE": "синхронизировал воспроизведение у всех",
|
"NOTIF_FORCE_EXECUTE": "синхронизировал воспроизведение у всех",
|
||||||
"DEBUG_NO_TAB": "Целевая вкладка не выбрана.",
|
"DEBUG_NO_TAB": "Целевая вкладка не выбрана.",
|
||||||
|
"DEBUG_TARGET_ACTIVATING": "Целевая вкладка выбрана; подготовка внедрения видео.",
|
||||||
"DEBUG_COMM_FAIL": "Не удалось связаться с плеером на вкладке.",
|
"DEBUG_COMM_FAIL": "Не удалось связаться с плеером на вкладке.",
|
||||||
"EMPTY_PEERS_TITLE": "Участников пока нет",
|
"EMPTY_PEERS_TITLE": "Участников пока нет",
|
||||||
"EMPTY_PEERS_HINT": "Поделитесь ссылкой-приглашением, чтобы начать",
|
"EMPTY_PEERS_HINT": "Поделитесь ссылкой-приглашением, чтобы начать",
|
||||||
|
|||||||
@@ -190,6 +190,7 @@
|
|||||||
"NOTIF_FORCE_PREPARE": "zorunlu eşitleme başlattı",
|
"NOTIF_FORCE_PREPARE": "zorunlu eşitleme başlattı",
|
||||||
"NOTIF_FORCE_EXECUTE": "herkesi eşitledi",
|
"NOTIF_FORCE_EXECUTE": "herkesi eşitledi",
|
||||||
"DEBUG_NO_TAB": "Hedef sekme seçilmedi.",
|
"DEBUG_NO_TAB": "Hedef sekme seçilmedi.",
|
||||||
|
"DEBUG_TARGET_ACTIVATING": "Hedef sekme seçildi; video enjeksiyonu hazırlanıyor.",
|
||||||
"DEBUG_COMM_FAIL": "Sekme videosuyla iletişim kurulamadı.",
|
"DEBUG_COMM_FAIL": "Sekme videosuyla iletişim kurulamadı.",
|
||||||
"EMPTY_PEERS_TITLE": "Henüz kimse yok",
|
"EMPTY_PEERS_TITLE": "Henüz kimse yok",
|
||||||
"EMPTY_PEERS_HINT": "Başlamak için davet bağlantınızı paylaşın",
|
"EMPTY_PEERS_HINT": "Başlamak için davet bağlantınızı paylaşın",
|
||||||
|
|||||||
@@ -190,6 +190,7 @@
|
|||||||
"NOTIF_FORCE_PREPARE": "почав примусову синхронізацію",
|
"NOTIF_FORCE_PREPARE": "почав примусову синхронізацію",
|
||||||
"NOTIF_FORCE_EXECUTE": "синхронізував усіх",
|
"NOTIF_FORCE_EXECUTE": "синхронізував усіх",
|
||||||
"DEBUG_NO_TAB": "Цільова вкладка не вибрана.",
|
"DEBUG_NO_TAB": "Цільова вкладка не вибрана.",
|
||||||
|
"DEBUG_TARGET_ACTIVATING": "Цільову вкладку вибрано; готується впровадження відео.",
|
||||||
"DEBUG_COMM_FAIL": "Не вдалося зв’язатися з відео вкладки.",
|
"DEBUG_COMM_FAIL": "Не вдалося зв’язатися з відео вкладки.",
|
||||||
"EMPTY_PEERS_TITLE": "Учасників ще немає",
|
"EMPTY_PEERS_TITLE": "Учасників ще немає",
|
||||||
"EMPTY_PEERS_HINT": "Поділіться своїм запрошенням, щоб почати",
|
"EMPTY_PEERS_HINT": "Поділіться своїм запрошенням, щоб почати",
|
||||||
|
|||||||
@@ -190,6 +190,7 @@
|
|||||||
"NOTIF_FORCE_PREPARE": "开始强制同步",
|
"NOTIF_FORCE_PREPARE": "开始强制同步",
|
||||||
"NOTIF_FORCE_EXECUTE": "同步所有人",
|
"NOTIF_FORCE_EXECUTE": "同步所有人",
|
||||||
"DEBUG_NO_TAB": "未选择目标选项卡。",
|
"DEBUG_NO_TAB": "未选择目标选项卡。",
|
||||||
|
"DEBUG_TARGET_ACTIVATING": "已选择目标标签页;正在准备注入视频脚本。",
|
||||||
"DEBUG_COMM_FAIL": "无法与标签视频通信。",
|
"DEBUG_COMM_FAIL": "无法与标签视频通信。",
|
||||||
"EMPTY_PEERS_TITLE": "还没有同行",
|
"EMPTY_PEERS_TITLE": "还没有同行",
|
||||||
"EMPTY_PEERS_HINT": "分享您的邀请链接以开始使用",
|
"EMPTY_PEERS_HINT": "分享您的邀请链接以开始使用",
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
export const MEDIA_FRAME_ACCESS_REQUIRED = 'media_frame_access_required';
|
export const MEDIA_FRAME_ACCESS_REQUIRED = 'media_frame_access_required';
|
||||||
export const MEDIA_FRAME_AMBIGUOUS = 'media_frame_ambiguous';
|
|
||||||
|
|
||||||
const MIN_PLAYER_FRAME_AREA = 320 * 180;
|
const MIN_PLAYER_FRAME_AREA = 320 * 180;
|
||||||
const MIN_PLAYER_ASPECT_RATIO = 1.15;
|
const MIN_PLAYER_ASPECT_RATIO = 1.15;
|
||||||
const MAX_PLAYER_ASPECT_RATIO = 2.6;
|
const MAX_PLAYER_ASPECT_RATIO = 2.6;
|
||||||
@@ -407,16 +405,10 @@ function accessRequiredError(access) {
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ambiguousFrameError() {
|
function contentTarget(tabId, selected, monitorTargets = null) {
|
||||||
const error = new Error('The active embedded video frame could not be identified safely');
|
|
||||||
error.code = MEDIA_FRAME_AMBIGUOUS;
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
function contentTarget(tabId, selected) {
|
|
||||||
const frameId = normalizeFrameId(selected?.frameId);
|
const frameId = normalizeFrameId(selected?.frameId);
|
||||||
const documentId = typeof selected?.documentId === 'string' ? selected.documentId : null;
|
const documentId = typeof selected?.documentId === 'string' ? selected.documentId : null;
|
||||||
return {
|
const target = {
|
||||||
frameId,
|
frameId,
|
||||||
documentId,
|
documentId,
|
||||||
frameUrl: typeof selected?.result?.href === 'string' ? selected.result.href : null,
|
frameUrl: typeof selected?.result?.href === 'string' ? selected.result.href : null,
|
||||||
@@ -425,16 +417,57 @@ function contentTarget(tabId, selected) {
|
|||||||
? { tabId, documentIds: [documentId] }
|
? { tabId, documentIds: [documentId] }
|
||||||
: (frameId === 0 ? { tabId } : { tabId, frameIds: [frameId] })
|
: (frameId === 0 ? { tabId } : { tabId, frameIds: [frameId] })
|
||||||
};
|
};
|
||||||
|
if (Array.isArray(monitorTargets) && monitorTargets.length > 0) {
|
||||||
|
target.monitorTargets = monitorTargets;
|
||||||
|
}
|
||||||
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listMediaFrameScriptTargets(tabId) {
|
export function listMediaFrameScriptTargets(tabId) {
|
||||||
return [{ tabId, allFrames: true }];
|
return [{ tabId, allFrames: true }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listFrameProbeTargets(tabId, embeddedFrameCount = 0) {
|
||||||
|
// Chromium can reject one all-frames executeScript call when a single
|
||||||
|
// child frame is browser-owned or temporarily unavailable. Frame IDs are
|
||||||
|
// not exposed without webNavigation, so probe a bounded range individually
|
||||||
|
// after the top frame tells us that embedded frames exist. Each rejected
|
||||||
|
// probe is isolated and cannot hide the other frames.
|
||||||
|
const maxFrameId = Math.min(64, Math.max(8, (embeddedFrameCount * 4) + 4));
|
||||||
|
return Array.from({ length: maxFrameId }, (_, frameId) => ({
|
||||||
|
tabId,
|
||||||
|
frameIds: [frameId]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameScriptTarget(tabId, entry) {
|
||||||
|
const frameId = normalizeFrameId(entry?.frameId);
|
||||||
|
return typeof entry?.documentId === 'string' && entry.documentId
|
||||||
|
? { tabId, documentIds: [entry.documentId] }
|
||||||
|
: (frameId === 0 ? { tabId, frameIds: [0] } : { tabId, frameIds: [frameId] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeFrameResults(...groups) {
|
||||||
|
const merged = new Map();
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const entry of Array.isArray(group) ? group : []) {
|
||||||
|
if (!Number.isInteger(entry?.frameId)) continue;
|
||||||
|
// A frame ID identifies the current slot. If its document changed
|
||||||
|
// between the broad probe and the exact probe, the exact result
|
||||||
|
// must replace the stale document rather than create a duplicate
|
||||||
|
// candidate that can trigger a false ambiguity.
|
||||||
|
const key = `frame:${entry.frameId}`;
|
||||||
|
merged.set(key, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(merged.values());
|
||||||
|
}
|
||||||
|
|
||||||
async function executeInAccessibleFrames(chromeApi, targets, func, args) {
|
async function executeInAccessibleFrames(chromeApi, targets, func, args) {
|
||||||
const settled = await Promise.all(targets.map(async target => {
|
const settled = await Promise.all(targets.map(async target => {
|
||||||
try {
|
try {
|
||||||
return await chromeApi.scripting.executeScript({ target, func, args });
|
const result = await chromeApi.scripting.executeScript({ target, func, args });
|
||||||
|
return Array.isArray(result) ? result : [];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -449,34 +482,44 @@ export async function resolveMediaContentTarget(chromeApi, tabId, {
|
|||||||
} = {}) {
|
} = {}) {
|
||||||
let fallback = null;
|
let fallback = null;
|
||||||
let missingAccess = null;
|
let missingAccess = null;
|
||||||
let ambiguous = false;
|
let monitorTargets = [];
|
||||||
|
|
||||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||||
const scriptTargets = listMediaFrameScriptTargets(tabId);
|
const topResults = await executeInAccessibleFrames(
|
||||||
let results = await executeInAccessibleFrames(
|
|
||||||
chromeApi,
|
chromeApi,
|
||||||
scriptTargets,
|
[{ tabId, frameIds: [0] }],
|
||||||
inspectMediaFrame,
|
inspectMediaFrame,
|
||||||
[null]
|
[null]
|
||||||
);
|
);
|
||||||
if (results.length === 0) {
|
const allFrameResults = await executeInAccessibleFrames(
|
||||||
try {
|
chromeApi,
|
||||||
results = await chromeApi.scripting.executeScript({
|
listMediaFrameScriptTargets(tabId),
|
||||||
target: { tabId },
|
inspectMediaFrame,
|
||||||
func: inspectMediaFrame,
|
[null]
|
||||||
args: [null]
|
);
|
||||||
});
|
let results = mergeFrameResults(topResults, allFrameResults);
|
||||||
} catch {
|
const embeddedFrameCount = topResults.reduce(
|
||||||
return contentTarget(tabId, null);
|
(count, entry) => Math.max(count, entry?.result?.embeddedFrames?.length || 0),
|
||||||
}
|
0
|
||||||
|
);
|
||||||
|
if (embeddedFrameCount > 0) {
|
||||||
|
const individuallyProbed = await executeInAccessibleFrames(
|
||||||
|
chromeApi,
|
||||||
|
listFrameProbeTargets(tabId, embeddedFrameCount),
|
||||||
|
inspectMediaFrame,
|
||||||
|
[null]
|
||||||
|
);
|
||||||
|
results = mergeFrameResults(results, individuallyProbed);
|
||||||
}
|
}
|
||||||
|
if (results.length === 0) return contentTarget(tabId, null);
|
||||||
|
|
||||||
if (results.length > 1) {
|
if (results.length > 1) {
|
||||||
const token = `${tabId}:${attempt}:${Date.now()}:${Math.random()}`;
|
const token = `${tabId}:${attempt}:${Date.now()}:${Math.random()}`;
|
||||||
|
const frameTargets = results.map(entry => frameScriptTarget(tabId, entry));
|
||||||
try {
|
try {
|
||||||
await executeInAccessibleFrames(
|
await executeInAccessibleFrames(
|
||||||
chromeApi,
|
chromeApi,
|
||||||
scriptTargets,
|
frameTargets,
|
||||||
installParentFrameVisibilityProbe,
|
installParentFrameVisibilityProbe,
|
||||||
[token]
|
[token]
|
||||||
);
|
);
|
||||||
@@ -484,7 +527,7 @@ export async function resolveMediaContentTarget(chromeApi, tabId, {
|
|||||||
for (let pass = 0; pass < 4; pass++) {
|
for (let pass = 0; pass < 4; pass++) {
|
||||||
await executeInAccessibleFrames(
|
await executeInAccessibleFrames(
|
||||||
chromeApi,
|
chromeApi,
|
||||||
scriptTargets,
|
frameTargets,
|
||||||
dispatchParentFrameVisibilityProbe,
|
dispatchParentFrameVisibilityProbe,
|
||||||
[token]
|
[token]
|
||||||
);
|
);
|
||||||
@@ -492,29 +535,29 @@ export async function resolveMediaContentTarget(chromeApi, tabId, {
|
|||||||
}
|
}
|
||||||
const inspected = await executeInAccessibleFrames(
|
const inspected = await executeInAccessibleFrames(
|
||||||
chromeApi,
|
chromeApi,
|
||||||
scriptTargets,
|
frameTargets,
|
||||||
inspectMediaFrame,
|
inspectMediaFrame,
|
||||||
[token]
|
[token]
|
||||||
);
|
);
|
||||||
if (inspected.length > 0) results = inspected;
|
if (inspected.length > 0) results = mergeFrameResults(results, inspected);
|
||||||
} catch {
|
} catch {
|
||||||
// Initial results remain usable, but equally-ranked unknown
|
// Initial results remain usable, but equally-ranked unknown
|
||||||
// frames will be rejected below rather than guessed.
|
// frames will be rejected below rather than guessed.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Rebuild these after the visibility refresh so a frame navigation that
|
||||||
|
// replaced its document ID cannot leave a stale monitor target behind.
|
||||||
|
monitorTargets = results.map(entry => frameScriptTarget(tabId, entry));
|
||||||
|
|
||||||
const selected = selectMediaFrame(results);
|
const selected = selectMediaFrame(results);
|
||||||
const videoCandidates = results.filter(entry => entry?.result?.bestVideo?.rendered === true
|
|
||||||
&& entry.result.parentFrameVisible !== false);
|
|
||||||
const currentMissingAccess = findMissingPlayerAccess(results);
|
const currentMissingAccess = findMissingPlayerAccess(results);
|
||||||
missingAccess = currentMissingAccess;
|
missingAccess = currentMissingAccess;
|
||||||
fallback = selected;
|
fallback = selected;
|
||||||
ambiguous = !selected && videoCandidates.length > 1;
|
|
||||||
if (selected) {
|
if (selected) {
|
||||||
if (selected.result.bestVideo.hasSource
|
if (selected.result.bestVideo.hasSource
|
||||||
&& selected.result.bestVideo.rendered
|
&& selected.result.bestVideo.rendered
|
||||||
&& !shouldPreferMissingAccess(currentMissingAccess, selected)) {
|
&& !shouldPreferMissingAccess(currentMissingAccess, selected)) {
|
||||||
return contentTarget(tabId, selected);
|
return contentTarget(tabId, selected, monitorTargets);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,7 +567,10 @@ export async function resolveMediaContentTarget(chromeApi, tabId, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (missingAccess) throw accessRequiredError(missingAccess);
|
if (missingAccess) throw accessRequiredError(missingAccess);
|
||||||
if (fallback) return contentTarget(tabId, fallback);
|
if (fallback) return contentTarget(tabId, fallback, monitorTargets);
|
||||||
if (ambiguous) throw ambiguousFrameError();
|
// Selecting a tab must not depend on video detection. A page can be a
|
||||||
return contentTarget(tabId, null);
|
// valid target before its player exists, and an ambiguous frame layout is
|
||||||
|
// recoverable through the injected lifecycle monitor. Keep the top-frame
|
||||||
|
// target active instead of discarding the user's selection.
|
||||||
|
return contentTarget(tabId, null, monitorTargets);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
MEDIA_FRAME_ACCESS_REQUIRED,
|
MEDIA_FRAME_ACCESS_REQUIRED,
|
||||||
MEDIA_FRAME_AMBIGUOUS,
|
|
||||||
inspectMediaFrame,
|
inspectMediaFrame,
|
||||||
resolveMediaContentTarget,
|
resolveMediaContentTarget,
|
||||||
selectMediaFrame
|
selectMediaFrame
|
||||||
@@ -128,12 +127,16 @@ describe('cross-origin media-frame targeting', () => {
|
|||||||
documentId: 'document-8',
|
documentId: 'document-8',
|
||||||
frameUrl: 'https://player-8.example/embed',
|
frameUrl: 'https://player-8.example/embed',
|
||||||
hasVideo: true,
|
hasVideo: true,
|
||||||
scriptTarget: { tabId: 42, documentIds: ['document-8'] }
|
scriptTarget: { tabId: 42, documentIds: ['document-8'] },
|
||||||
|
monitorTargets: [
|
||||||
|
{ tabId: 42, documentIds: ['document-0'] },
|
||||||
|
{ tabId: 42, documentIds: ['document-8'] }
|
||||||
|
]
|
||||||
});
|
});
|
||||||
const visibilityDispatches = executeScript.mock.calls.filter(([options]) => (
|
const visibilityDispatches = executeScript.mock.calls.filter(([options]) => (
|
||||||
options.func?.name === 'dispatchParentFrameVisibilityProbe'
|
options.func?.name === 'dispatchParentFrameVisibilityProbe'
|
||||||
));
|
));
|
||||||
expect(visibilityDispatches).toHaveLength(4);
|
expect(visibilityDispatches.length).toBeGreaterThanOrEqual(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the top target inactive when the only discovered video is hidden', async () => {
|
it('keeps the top target inactive when the only discovered video is hidden', async () => {
|
||||||
@@ -151,7 +154,11 @@ describe('cross-origin media-frame targeting', () => {
|
|||||||
documentId: null,
|
documentId: null,
|
||||||
frameUrl: null,
|
frameUrl: null,
|
||||||
hasVideo: false,
|
hasVideo: false,
|
||||||
scriptTarget: { tabId: 42 }
|
scriptTarget: { tabId: 42 },
|
||||||
|
monitorTargets: [
|
||||||
|
{ tabId: 42, documentIds: ['document-0'] },
|
||||||
|
{ tabId: 42, documentIds: ['document-6'] }
|
||||||
|
]
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -165,9 +172,108 @@ describe('cross-origin media-frame targeting', () => {
|
|||||||
)).resolves.toMatchObject({
|
)).resolves.toMatchObject({
|
||||||
frameId: 8,
|
frameId: 8,
|
||||||
hasVideo: true,
|
hasVideo: true,
|
||||||
scriptTarget: { tabId: 42, documentIds: ['document-8'] }
|
scriptTarget: { tabId: 42, documentIds: ['document-8'] },
|
||||||
|
monitorTargets: expect.arrayContaining([
|
||||||
|
{ tabId: 42, documentIds: ['document-8'] }
|
||||||
|
])
|
||||||
|
});
|
||||||
|
expect(executeScript.mock.calls.some(([options]) => (
|
||||||
|
options.target?.tabId === 42 && options.target?.allFrames === true
|
||||||
|
))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isolates a rejected all-frame sweep and still finds an embedded player', async () => {
|
||||||
|
const top = frame(0, {
|
||||||
|
href: 'https://anime.example/watch',
|
||||||
|
origin: 'https://anime.example',
|
||||||
|
bestVideo: null,
|
||||||
|
videoCount: 0,
|
||||||
|
embeddedFrames: [{
|
||||||
|
href: 'https://player.example/embed',
|
||||||
|
origin: 'https://player.example',
|
||||||
|
area: 860 * 490,
|
||||||
|
width: 860,
|
||||||
|
height: 490,
|
||||||
|
visible: true,
|
||||||
|
mediaHint: true
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
const player = frame(1, {
|
||||||
|
href: 'https://player.example/embed'
|
||||||
|
});
|
||||||
|
const executeScript = vi.fn(async options => {
|
||||||
|
if (options.target?.allFrames === true) throw new Error('one frame rejected');
|
||||||
|
const frameId = options.target?.frameIds?.[0];
|
||||||
|
if (options.func?.name === 'inspectMediaFrame') {
|
||||||
|
if (frameId === 0) return [top];
|
||||||
|
if (frameId === 1) return [player];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(resolveMediaContentTarget(
|
||||||
|
{ scripting: { executeScript } },
|
||||||
|
47,
|
||||||
|
{ attempts: 1, probeDelayMs: 0 }
|
||||||
|
)).resolves.toMatchObject({
|
||||||
|
frameId: 1,
|
||||||
|
documentId: 'document-1',
|
||||||
|
hasVideo: true,
|
||||||
|
scriptTarget: { tabId: 47, documentIds: ['document-1'] },
|
||||||
|
monitorTargets: expect.arrayContaining([
|
||||||
|
{ tabId: 47, documentIds: ['document-0'] },
|
||||||
|
{ tabId: 47, documentIds: ['document-1'] }
|
||||||
|
])
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sweeps individual frame IDs when an all-frame result is partial', async () => {
|
||||||
|
const top = frame(0, {
|
||||||
|
href: 'https://anime.example/watch',
|
||||||
|
origin: 'https://anime.example',
|
||||||
|
bestVideo: null,
|
||||||
|
videoCount: 0,
|
||||||
|
embeddedFrames: [{
|
||||||
|
href: 'https://player.example/embed',
|
||||||
|
origin: 'https://player.example',
|
||||||
|
area: 860 * 490,
|
||||||
|
width: 860,
|
||||||
|
height: 490,
|
||||||
|
visible: true,
|
||||||
|
mediaHint: true
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
const partialFrame = frame(2, { bestVideo: null, videoCount: 0 });
|
||||||
|
const player = frame(3, { href: 'https://player.example/embed' });
|
||||||
|
const executeScript = vi.fn(async options => {
|
||||||
|
if (options.target?.allFrames === true) {
|
||||||
|
return options.func?.name === 'inspectMediaFrame' ? [top, partialFrame] : [];
|
||||||
|
}
|
||||||
|
const frameId = options.target?.frameIds?.[0];
|
||||||
|
if (options.func?.name === 'inspectMediaFrame') {
|
||||||
|
if (frameId === 0) return [top];
|
||||||
|
if (frameId === 3) return [player];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(resolveMediaContentTarget(
|
||||||
|
{ scripting: { executeScript } },
|
||||||
|
48,
|
||||||
|
{ attempts: 1, probeDelayMs: 0 }
|
||||||
|
)).resolves.toMatchObject({
|
||||||
|
frameId: 3,
|
||||||
|
documentId: 'document-3',
|
||||||
|
hasVideo: true,
|
||||||
|
scriptTarget: { tabId: 48, documentIds: ['document-3'] },
|
||||||
|
monitorTargets: expect.arrayContaining([
|
||||||
|
{ tabId: 48, documentIds: ['document-0'] },
|
||||||
|
{ tabId: 48, documentIds: ['document-2'] },
|
||||||
|
{ tabId: 48, documentIds: ['document-3'] }
|
||||||
|
])
|
||||||
});
|
});
|
||||||
expect(executeScript.mock.calls[0][0].target).toEqual({ tabId: 42, allFrames: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not trust parent visibility from an older probe token', () => {
|
it('does not trust parent visibility from an older probe token', () => {
|
||||||
@@ -432,7 +538,7 @@ describe('cross-origin media-frame targeting', () => {
|
|||||||
)).resolves.toMatchObject({ frameId: 0, scriptTarget: { tabId: 44 } });
|
)).resolves.toMatchObject({ frameId: 0, scriptTarget: { tabId: 44 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports ambiguity rather than controlling an arbitrary equal player', async () => {
|
it('keeps the tab target active when equal player frames are ambiguous', async () => {
|
||||||
const results = [
|
const results = [
|
||||||
frame(3, { parentFrameVisible: null }),
|
frame(3, { parentFrameVisible: null }),
|
||||||
frame(4, { parentFrameVisible: null })
|
frame(4, { parentFrameVisible: null })
|
||||||
@@ -442,6 +548,10 @@ describe('cross-origin media-frame targeting', () => {
|
|||||||
{ scripting: { executeScript } },
|
{ scripting: { executeScript } },
|
||||||
45,
|
45,
|
||||||
{ attempts: 1, probeDelayMs: 0 }
|
{ attempts: 1, probeDelayMs: 0 }
|
||||||
)).rejects.toMatchObject({ code: MEDIA_FRAME_AMBIGUOUS });
|
)).resolves.toMatchObject({
|
||||||
|
frameId: 0,
|
||||||
|
hasVideo: false,
|
||||||
|
scriptTarget: { tabId: 45 }
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+37
-24
@@ -468,12 +468,12 @@ async function init() {
|
|||||||
|
|
||||||
// Keep a denied selection visible while Chrome waits for the user
|
// Keep a denied selection visible while Chrome waits for the user
|
||||||
// to grant access; it becomes active automatically after approval.
|
// to grant access; it becomes active automatically after approval.
|
||||||
await populateTabs(res.peers, getSelectedTargetTabId(res));
|
await populateTabs(res.peers, res.targetTabId);
|
||||||
|
|
||||||
// Render lobby status if active
|
// Render lobby status if active
|
||||||
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
|
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
|
||||||
|
|
||||||
if (res.status === 'connected' && !getSelectedTargetTabId(res) && localData.roomId) {
|
if (res.status === 'connected' && normalizeTabId(res.targetTabId) === null && localData.roomId) {
|
||||||
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
|
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
|
||||||
if (syncTabBtn) syncTabBtn.click();
|
if (syncTabBtn) syncTabBtn.click();
|
||||||
showSelectVideoHint();
|
showSelectVideoHint();
|
||||||
@@ -647,15 +647,6 @@ function handleTargetTabResponse(response) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectedTargetTabId(status) {
|
|
||||||
return status?.selectedTargetTabId
|
|
||||||
?? status?.requestedTargetTabId
|
|
||||||
?? status?.targetTabId
|
|
||||||
?? status?.pendingTargetTabId
|
|
||||||
?? status?.activatingTargetTabId
|
|
||||||
?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectTargetTab(tabId, tabTitle) {
|
function selectTargetTab(tabId, tabTitle) {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId, tabTitle }, response => {
|
chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId, tabTitle }, response => {
|
||||||
@@ -691,7 +682,7 @@ async function refreshTargetAccessState() {
|
|||||||
}
|
}
|
||||||
await populateTabs(
|
await populateTabs(
|
||||||
status.peers,
|
status.peers,
|
||||||
getSelectedTargetTabId(status)
|
status.targetTabId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1231,14 +1222,14 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
|||||||
const blacklistDomains = getEffectiveBlacklistDomains(await readBlacklistOverrides());
|
const blacklistDomains = getEffectiveBlacklistDomains(await readBlacklistOverrides());
|
||||||
const isFilterActive = data.filterNoise !== false;
|
const isFilterActive = data.filterNoise !== false;
|
||||||
|
|
||||||
let currentTargetTabId = providedTargetTabId;
|
let currentTargetTabId = normalizeTabId(providedTargetTabId);
|
||||||
if (currentTargetTabId === null) {
|
if (currentTargetTabId === null) {
|
||||||
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
|
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
|
||||||
if (chrome.runtime.lastError) {
|
if (chrome.runtime.lastError) {
|
||||||
if (populateTabsToken !== token) return;
|
if (populateTabsToken !== token) return;
|
||||||
currentTargetTabId = null;
|
currentTargetTabId = null;
|
||||||
} else {
|
} else {
|
||||||
currentTargetTabId = getSelectedTargetTabId(status);
|
currentTargetTabId = status?.targetTabId ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1269,7 +1260,7 @@ 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 && currentTargetTabId !== null && tab.id !== currentTargetTabId) {
|
||||||
if (isUrlBlacklisted(tab.url, blacklistDomains)) return false;
|
if (isUrlBlacklisted(tab.url, blacklistDomains)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -1320,7 +1311,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
|||||||
// Sort: 1. Current tab first, 2. Matches, 3. Rest alphabetically
|
// Sort: 1. Current tab first, 2. Matches, 3. Rest alphabetically
|
||||||
const options = Array.from(elements.targetTab.options);
|
const options = Array.from(elements.targetTab.options);
|
||||||
const placeholder = options.shift();
|
const placeholder = options.shift();
|
||||||
const currentTabId = currentTargetTabId ? parseInt(currentTargetTabId) : null;
|
const currentTabId = currentTargetTabId;
|
||||||
|
|
||||||
options.sort((a, b) => {
|
options.sort((a, b) => {
|
||||||
const aId = parseInt(a.value);
|
const aId = parseInt(a.value);
|
||||||
@@ -1340,7 +1331,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
|||||||
elements.targetTab.appendChild(placeholder);
|
elements.targetTab.appendChild(placeholder);
|
||||||
options.forEach(opt => elements.targetTab.appendChild(opt));
|
options.forEach(opt => elements.targetTab.appendChild(opt));
|
||||||
|
|
||||||
if (currentTargetTabId) {
|
if (currentTargetTabId !== null) {
|
||||||
elements.targetTab.value = currentTargetTabId;
|
elements.targetTab.value = currentTargetTabId;
|
||||||
} else {
|
} else {
|
||||||
const matchOpt = options.find(o => o.textContent.includes('⭐ MATCH:'));
|
const matchOpt = options.find(o => o.textContent.includes('⭐ MATCH:'));
|
||||||
@@ -1754,7 +1745,7 @@ if (elements.langSelector) {
|
|||||||
} else {
|
} else {
|
||||||
hideSiteAccessNotice();
|
hideSiteAccessNotice();
|
||||||
}
|
}
|
||||||
await populateTabs(res.peers, getSelectedTargetTabId(res));
|
await populateTabs(res.peers, res.targetTabId);
|
||||||
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
|
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
|
||||||
} else {
|
} else {
|
||||||
applyConnectionStatus('disconnected');
|
applyConnectionStatus('disconnected');
|
||||||
@@ -2103,7 +2094,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
|||||||
elements.forceSyncBtn.disabled = true;
|
elements.forceSyncBtn.disabled = true;
|
||||||
|
|
||||||
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
|
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
|
||||||
if (chrome.runtime.lastError || !status || !status.targetTabId) {
|
if (chrome.runtime.lastError || !status || status.targetReady !== true || normalizeTabId(status.targetTabId) === null) {
|
||||||
elements.forceSyncBtn.disabled = false;
|
elements.forceSyncBtn.disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2169,7 +2160,8 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
|||||||
resolve(false);
|
resolve(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolve(normalizeTabId(currentStatus?.targetTabId) === tabId);
|
resolve(currentStatus?.targetReady === true
|
||||||
|
&& normalizeTabId(currentStatus?.targetTabId) === tabId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2588,8 +2580,9 @@ elements.copyLogs.addEventListener('click', () => {
|
|||||||
logs = logs || [];
|
logs = logs || [];
|
||||||
history = history || [];
|
history = history || [];
|
||||||
|
|
||||||
const videoPromise = (status && status.targetTabId)
|
const targetTabId = normalizeTabId(status.targetTabId);
|
||||||
? new Promise(resolve => chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId: status.targetTabId }, resolve))
|
const videoPromise = (targetTabId !== null && status.targetReady === true)
|
||||||
|
? new Promise(resolve => chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId: targetTabId }, resolve))
|
||||||
: Promise.resolve(null);
|
: Promise.resolve(null);
|
||||||
|
|
||||||
videoPromise.then(rawVideo => {
|
videoPromise.then(rawVideo => {
|
||||||
@@ -2609,6 +2602,11 @@ elements.copyLogs.addEventListener('click', () => {
|
|||||||
lines.push(`- **User Agent:** ${userAgent}`);
|
lines.push(`- **User Agent:** ${userAgent}`);
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
||||||
|
lines.push('## Target');
|
||||||
|
lines.push(`- **Target Tab ID:** ${targetTabId ?? 'none'}`);
|
||||||
|
lines.push(`- **Activation:** ${safe(status.targetActivationState, 'unknown')}`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
// ── Tab ──
|
// ── Tab ──
|
||||||
if (rawVideo) {
|
if (rawVideo) {
|
||||||
lines.push('## Tab');
|
lines.push('## Tab');
|
||||||
@@ -2661,7 +2659,9 @@ elements.copyLogs.addEventListener('click', () => {
|
|||||||
// ── Video ──
|
// ── Video ──
|
||||||
lines.push('## Video');
|
lines.push('## Video');
|
||||||
if (!rawVideo) {
|
if (!rawVideo) {
|
||||||
lines.push('- *No tab selected / communication failed*');
|
lines.push(targetTabId !== null
|
||||||
|
? '- *Target tab selected; video communication is not ready yet*'
|
||||||
|
: '- *No tab selected / communication failed*');
|
||||||
} else if (!vs.found) {
|
} else if (!vs.found) {
|
||||||
lines.push('- **Found:** \u274C NO VIDEO ELEMENT');
|
lines.push('- **Found:** \u274C NO VIDEO ELEMENT');
|
||||||
if (vs.videoCount != null) lines.push(`- **Video Tags:** ${vs.videoCount}`);
|
if (vs.videoCount != null) lines.push(`- **Video Tags:** ${vs.videoCount}`);
|
||||||
@@ -2801,11 +2801,24 @@ function refreshDebugInfo() {
|
|||||||
if (!devTab || devTab.style.display === 'none') return;
|
if (!devTab || devTab.style.display === 'none') return;
|
||||||
|
|
||||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
||||||
if (!res || !res.targetTabId) {
|
if (!res || normalizeTabId(res.targetTabId) === null) {
|
||||||
|
if (res?.targetActivationState === 'activating' || res?.targetActivationState === 'access_required') {
|
||||||
|
if (elements.videoDebug) {
|
||||||
|
elements.videoDebug.textContent = getMessage('DEBUG_TARGET_ACTIVATING');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (elements.videoDebug) elements.videoDebug.textContent = getMessage('DEBUG_NO_TAB');
|
if (elements.videoDebug) elements.videoDebug.textContent = getMessage('DEBUG_NO_TAB');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (res.targetReady !== true) {
|
||||||
|
if (elements.videoDebug) {
|
||||||
|
elements.videoDebug.textContent = getMessage('DEBUG_TARGET_ACTIVATING');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Request direct state from the content script via background
|
// Request direct state from the content script via background
|
||||||
chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId: res.targetTabId }, (state) => {
|
chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId: res.targetTabId }, (state) => {
|
||||||
if (!state || (!state.found && state.error)) {
|
if (!state || (!state.found && state.error)) {
|
||||||
|
|||||||
@@ -34,8 +34,12 @@ describe('target tab lifecycle', () => {
|
|||||||
it('fully deactivates old and superseded target injections', () => {
|
it('fully deactivates old and superseded target injections', () => {
|
||||||
expect(backgroundSource).toContain("{ type: 'TARGET_DEACTIVATE' }");
|
expect(backgroundSource).toContain("{ type: 'TARGET_DEACTIVATE' }");
|
||||||
expect(backgroundSource).toContain('target.documentId');
|
expect(backgroundSource).toContain('target.documentId');
|
||||||
|
expect(backgroundSource).toContain('await resetAudioProcessingInTab(normalizedTabId, target);');
|
||||||
|
expect(backgroundSource).toContain("{ action: 'RESET_AUDIO_PROCESSING' }");
|
||||||
expect(backgroundSource.match(/await deactivateTargetTab\(selectedTabId,/g)?.length).toBeGreaterThanOrEqual(6);
|
expect(backgroundSource.match(/await deactivateTargetTab\(selectedTabId,/g)?.length).toBeGreaterThanOrEqual(6);
|
||||||
expect(contentSource).toContain("if (message.type === 'TARGET_DEACTIVATE')");
|
expect(contentSource).toContain("if (message.type === 'TARGET_DEACTIVATE')");
|
||||||
|
expect(contentSource).toContain('destroyContentScript({ preserveAudioRoute: true });');
|
||||||
|
expect(contentSource).toContain('window.__koalaSyncAudioRoute');
|
||||||
expect(overlaySource).toContain("message?.type === 'TARGET_DEACTIVATE'");
|
expect(overlaySource).toContain("message?.type === 'TARGET_DEACTIVATE'");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,12 +75,14 @@ describe('target tab lifecycle', () => {
|
|||||||
|
|
||||||
it('keeps the selected frame recoverable when an all-frame sweep is rejected', () => {
|
it('keeps the selected frame recoverable when an all-frame sweep is rejected', () => {
|
||||||
expect(backgroundSource).toContain('contentTarget?.scriptTarget');
|
expect(backgroundSource).toContain('contentTarget?.scriptTarget');
|
||||||
|
expect(backgroundSource).toContain('...(contentTarget?.monitorTargets || [])');
|
||||||
expect(backgroundSource).toContain('function uniqueScriptTargets(targets)');
|
expect(backgroundSource).toContain('function uniqueScriptTargets(targets)');
|
||||||
expect(backgroundSource).toContain('function deactivateMediaFrameMonitor()');
|
expect(backgroundSource).toContain('function deactivateMediaFrameMonitor()');
|
||||||
expect(backgroundSource).toContain('func: deactivateMediaFrameMonitor');
|
expect(backgroundSource).toContain('func: deactivateMediaFrameMonitor');
|
||||||
expect(backgroundSource).toContain('isMissingContentReceiverError(error)');
|
expect(backgroundSource).toContain('isMissingContentReceiverError(error)');
|
||||||
expect(backgroundSource).toContain('await refreshCurrentMediaTarget(tabId, { queueIfRunning: true })');
|
expect(backgroundSource).toContain('await refreshCurrentMediaTarget(tabId, { queueIfRunning: true })');
|
||||||
expect(backgroundSource).toContain("activation?.status === 'activation_in_progress'");
|
expect(backgroundSource).toContain("activation?.status === 'activation_in_progress'");
|
||||||
|
expect(backgroundSource).not.toContain('Media frame probe fell back to the top frame');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not discard a selected tab when its media frame refresh is transiently unavailable', () => {
|
it('does not discard a selected tab when its media frame refresh is transiently unavailable', () => {
|
||||||
@@ -101,10 +107,13 @@ describe('target tab lifecycle', () => {
|
|||||||
expect(backgroundSource).toContain('await rememberRequestedTarget(selectedTabId, message.tabTitle);');
|
expect(backgroundSource).toContain('await rememberRequestedTarget(selectedTabId, message.tabTitle);');
|
||||||
expect(backgroundSource).toContain('pendingRequestedActivationCount > 0');
|
expect(backgroundSource).toContain('pendingRequestedActivationCount > 0');
|
||||||
expect(backgroundSource).toContain('await retryRequestedTarget();');
|
expect(backgroundSource).toContain('await retryRequestedTarget();');
|
||||||
expect(backgroundSource).toContain('selectedTargetTabId: requestedTarget ?? activatingTarget ?? normalizeTabId(currentTabId)');
|
expect(backgroundSource).toContain('targetTabId,');
|
||||||
|
expect(backgroundSource).toContain('targetReady');
|
||||||
|
expect(backgroundSource).toContain("targetActivationState");
|
||||||
expect(backgroundSource).toContain('await clearRequestedTarget(selectedTabId);');
|
expect(backgroundSource).toContain('await clearRequestedTarget(selectedTabId);');
|
||||||
expect(popupSource).toContain('function getSelectedTargetTabId(status)');
|
expect(popupSource).not.toContain('getSelectedTargetTabId');
|
||||||
expect(popupSource).toContain('await populateTabs(res.peers, getSelectedTargetTabId(res));');
|
expect(popupSource).toContain('await populateTabs(res.peers, res.targetTabId);');
|
||||||
|
expect(popupSource).toContain('res.targetReady !== true');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('serializes content commands and coalesces target refreshes', () => {
|
it('serializes content commands and coalesces target refreshes', () => {
|
||||||
@@ -118,7 +127,7 @@ describe('target tab lifecycle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('tears down every persistent content-script resource', () => {
|
it('tears down every persistent content-script resource', () => {
|
||||||
expect(contentSource).toContain('function destroyContentScript()');
|
expect(contentSource).toContain('function destroyContentScript({ preserveAudioRoute = false } = {})');
|
||||||
expect(contentSource).toContain('observer.disconnect()');
|
expect(contentSource).toContain('observer.disconnect()');
|
||||||
expect(contentSource).toContain('keepAlivePort.disconnect()');
|
expect(contentSource).toContain('keepAlivePort.disconnect()');
|
||||||
expect(contentSource).toContain('for (const video of [...attachedVideos]) detachVideoListeners(video);');
|
expect(contentSource).toContain('for (const video of [...attachedVideos]) detachVideoListeners(video);');
|
||||||
|
|||||||
@@ -54,6 +54,62 @@ async function getExtensionState(context, extensionId, message) {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setAudioSettings(context, extensionId, settings) {
|
||||||
|
return withExtensionPage(context, extensionId, page => page.evaluate(
|
||||||
|
value => chrome.storage.local.set({ audioSettings: value }),
|
||||||
|
settings
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAudioRouteState(context, extensionId, pageUrl) {
|
||||||
|
return withExtensionPage(context, extensionId, page => page.evaluate(async url => {
|
||||||
|
const [tab] = await chrome.tabs.query({ url });
|
||||||
|
if (!tab) throw new Error(`no tab matched ${url}`);
|
||||||
|
const [result] = await chrome.scripting.executeScript({
|
||||||
|
target: { tabId: tab.id },
|
||||||
|
func: () => {
|
||||||
|
const route = window.__koalaSyncAudioRoute;
|
||||||
|
if (!route) {
|
||||||
|
return {
|
||||||
|
hasRoute: false,
|
||||||
|
contextState: null,
|
||||||
|
chainActive: null,
|
||||||
|
hasVideo: false,
|
||||||
|
signalLevel: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let analyser = route.testAnalyser;
|
||||||
|
if (!analyser) {
|
||||||
|
analyser = route.audioCtx.createAnalyser();
|
||||||
|
analyser.fftSize = 32;
|
||||||
|
route.chain.limiter.connect(analyser);
|
||||||
|
route.testAnalyser = analyser;
|
||||||
|
}
|
||||||
|
if (!route.testSignal) {
|
||||||
|
const oscillator = route.audioCtx.createOscillator();
|
||||||
|
const gain = route.audioCtx.createGain();
|
||||||
|
gain.gain.value = 0.05;
|
||||||
|
oscillator.frequency.value = 440;
|
||||||
|
oscillator.connect(gain);
|
||||||
|
gain.connect(route.chain.limiter);
|
||||||
|
oscillator.start();
|
||||||
|
route.testSignal = { oscillator, gain };
|
||||||
|
}
|
||||||
|
const samples = new Uint8Array(analyser.fftSize);
|
||||||
|
analyser.getByteTimeDomainData(samples);
|
||||||
|
return {
|
||||||
|
hasRoute: true,
|
||||||
|
contextState: route.audioCtx?.state || null,
|
||||||
|
chainActive: route.chain?.active ?? null,
|
||||||
|
hasVideo: !!route.video,
|
||||||
|
signalLevel: Math.max(...Array.from(samples, sample => Math.abs(sample - 128)))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result?.result || null;
|
||||||
|
}, pageUrl));
|
||||||
|
}
|
||||||
|
|
||||||
async function getFrameMonitorState(context, extensionId, pageUrl, frameUrlPart) {
|
async function getFrameMonitorState(context, extensionId, pageUrl, frameUrlPart) {
|
||||||
return withExtensionPage(context, extensionId, page => page.evaluate(async ({ pageUrl, frameUrlPart }) => {
|
return withExtensionPage(context, extensionId, page => page.evaluate(async ({ pageUrl, frameUrlPart }) => {
|
||||||
const [tab] = await chrome.tabs.query({ url: pageUrl });
|
const [tab] = await chrome.tabs.query({ url: pageUrl });
|
||||||
@@ -105,11 +161,82 @@ test('keeps the selected tab after the popup page closes and reopens', async ({
|
|||||||
const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
||||||
expect(status).toMatchObject({
|
expect(status).toMatchObject({
|
||||||
targetTabId: tabId,
|
targetTabId: tabId,
|
||||||
selectedTargetTabId: tabId,
|
targetReady: true,
|
||||||
requestedTargetTabId: null
|
targetActivationState: 'ready'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps a selected target even when the page has no video element', async ({ context, extensionId, baseURL }) => {
|
||||||
|
const url = `${baseURL}/pages/no-video.html`;
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(url);
|
||||||
|
await page.waitForFunction(() => window.__fixtureReady === true);
|
||||||
|
|
||||||
|
const { tabId, response } = await selectTargetTab(context, extensionId, url);
|
||||||
|
expect(response).toMatchObject({ status: 'ok', tabId });
|
||||||
|
|
||||||
|
const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
||||||
|
expect(status).toMatchObject({
|
||||||
|
targetTabId: tabId,
|
||||||
|
targetReady: true,
|
||||||
|
targetActivationState: 'ready',
|
||||||
|
targetHasVideo: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps a previously selected player audible after switching targets', async ({ context, extensionId, baseURL }) => {
|
||||||
|
const firstUrl = `${baseURL}/pages/simple-player.html`;
|
||||||
|
const secondUrl = `${baseURL}/pages/no-video.html`;
|
||||||
|
const first = await context.newPage();
|
||||||
|
const second = await context.newPage();
|
||||||
|
await first.goto(firstUrl);
|
||||||
|
await first.waitForFunction(() => window.__fixtureReady === true);
|
||||||
|
await second.goto(secondUrl);
|
||||||
|
await second.waitForFunction(() => window.__fixtureReady === true);
|
||||||
|
|
||||||
|
await setAudioSettings(context, extensionId, {
|
||||||
|
enabled: true,
|
||||||
|
boostDb: 1,
|
||||||
|
compressor: { enabled: false, preset: 'recommended', customParams: {} }
|
||||||
|
});
|
||||||
|
const { tabId: firstTabId } = await selectTargetTab(context, extensionId, firstUrl);
|
||||||
|
await first.evaluate(() => document.getElementById('player').play());
|
||||||
|
await expect.poll(
|
||||||
|
() => getAudioRouteState(context, extensionId, firstUrl),
|
||||||
|
{ message: 'the selected player must have a live audio route' }
|
||||||
|
).toMatchObject({ hasRoute: true, contextState: 'running', hasVideo: true });
|
||||||
|
await expect.poll(
|
||||||
|
() => getAudioRouteState(context, extensionId, firstUrl).then(state => state?.signalLevel || 0),
|
||||||
|
{ message: 'the selected player must produce a signal through the audio route' }
|
||||||
|
).toBeGreaterThan(1);
|
||||||
|
|
||||||
|
const { response } = await selectTargetTab(context, extensionId, secondUrl);
|
||||||
|
expect(response).toMatchObject({ status: 'ok' });
|
||||||
|
await first.evaluate(() => document.getElementById('player').play());
|
||||||
|
await expect.poll(() => first.locator('#player').evaluate(video => ({
|
||||||
|
paused: video.paused,
|
||||||
|
muted: video.muted,
|
||||||
|
volume: video.volume
|
||||||
|
}))).toMatchObject({ paused: false, muted: false });
|
||||||
|
await expect.poll(
|
||||||
|
() => getAudioRouteState(context, extensionId, firstUrl).then(state => state?.signalLevel || 0),
|
||||||
|
{ message: 'switching targets must not mute the previous audio route' }
|
||||||
|
).toBeGreaterThan(1);
|
||||||
|
|
||||||
|
await selectTargetTab(context, extensionId, firstUrl);
|
||||||
|
await expect.poll(
|
||||||
|
() => getAudioRouteState(context, extensionId, firstUrl),
|
||||||
|
{ message: 'reselecting the player must reuse its live audio route' }
|
||||||
|
).toMatchObject({ hasRoute: true, contextState: 'running', hasVideo: true });
|
||||||
|
await expect.poll(
|
||||||
|
() => getAudioRouteState(context, extensionId, firstUrl).then(state => state?.signalLevel || 0),
|
||||||
|
{ message: 'reselecting the player must keep the audio signal alive' }
|
||||||
|
).toBeGreaterThan(1);
|
||||||
|
const playResponse = await sendServerCommand(context, extensionId, firstTabId, 'play');
|
||||||
|
expect(playResponse).toMatchObject({ status: 'ok_solo' });
|
||||||
|
await expect.poll(() => first.locator('#player').evaluate(video => video.paused)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test('applies remote play, pause and seek to the framed player', async ({ context, extensionId, baseURL }) => {
|
test('applies remote play, pause and seek to the framed player', async ({ context, extensionId, baseURL }) => {
|
||||||
const url = `${baseURL}/pages/iframe-player.html`;
|
const url = `${baseURL}/pages/iframe-player.html`;
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="utf-8"><title>No media page</title></head>
|
||||||
|
<body>
|
||||||
|
<main><h1>Target page without a video element</h1><p>This page is intentionally selectable.</p></main>
|
||||||
|
<script>window.__fixtureReady = true;</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+58
-57
@@ -176,63 +176,6 @@ async function compile() {
|
|||||||
fs.mkdirSync(wwwDir, { recursive: true });
|
fs.mkdirSync(wwwDir, { recursive: true });
|
||||||
removeBuildMetadata(wwwDir);
|
removeBuildMetadata(wwwDir);
|
||||||
|
|
||||||
// ── 0. Auto-generate website logo sizes and sync favicons ──
|
|
||||||
console.log('Generating responsive website logos...');
|
|
||||||
const rawLogoSrc = path.join(websiteDir, '..', 'assets', 'icon', 'TwoPointZero_Logo_Icon_600.webp');
|
|
||||||
const targetAssetsDir = path.join(websiteDir, 'assets');
|
|
||||||
|
|
||||||
if (fs.existsSync(rawLogoSrc)) {
|
|
||||||
fs.mkdirSync(targetAssetsDir, { recursive: true });
|
|
||||||
|
|
||||||
// Generate NewLogoIcon_64.webp (64x64)
|
|
||||||
await sharp(rawLogoSrc)
|
|
||||||
.resize(64, 64)
|
|
||||||
.toFile(path.join(targetAssetsDir, 'NewLogoIcon_64.webp'));
|
|
||||||
|
|
||||||
// Generate NewLogoIcon_128.webp (128x128)
|
|
||||||
await sharp(rawLogoSrc)
|
|
||||||
.resize(128, 128)
|
|
||||||
.toFile(path.join(targetAssetsDir, 'NewLogoIcon_128.webp'));
|
|
||||||
|
|
||||||
// Generate NewLogoIcon.webp (256x256)
|
|
||||||
await sharp(rawLogoSrc)
|
|
||||||
.resize(256, 256)
|
|
||||||
.toFile(path.join(targetAssetsDir, 'NewLogoIcon.webp'));
|
|
||||||
|
|
||||||
console.log(' ✓ WebP logo variants successfully generated in website/assets/');
|
|
||||||
} else {
|
|
||||||
console.warn(` ⚠️ Warning: Source logo ${rawLogoSrc} not found. Skipping auto-generation.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pngMappings = [
|
|
||||||
{ src: 'TwoPointZero_Logo_Icon_16.png', dest: 'favicon-16x16.png' },
|
|
||||||
{ src: 'TwoPointZero_Logo_Icon_32.png', dest: 'favicon-32x32.png' },
|
|
||||||
{ src: 'TwoPointZero_Logo_Icon_256.png', dest: 'apple-touch-icon.png' },
|
|
||||||
{ src: 'TwoPointZero_Logo_Icon_256.png', dest: 'icon-192x192.png' }
|
|
||||||
];
|
|
||||||
for (const mapping of pngMappings) {
|
|
||||||
const srcPath = path.join(websiteDir, '..', 'assets', 'icon', mapping.src);
|
|
||||||
const destPath = path.join(targetAssetsDir, mapping.dest);
|
|
||||||
if (fs.existsSync(srcPath)) {
|
|
||||||
fs.copyFileSync(srcPath, destPath);
|
|
||||||
} else {
|
|
||||||
console.warn(` ⚠️ Warning: Source PNG ${srcPath} not found.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(' ✓ Favicons/touch icons successfully synced to website/assets/');
|
|
||||||
|
|
||||||
// Social link preview image (og:image / twitter:image), same artwork as the
|
|
||||||
// GitHub repository OpenGraph card. PNG kept for maximum scraper support.
|
|
||||||
const ogSrc = path.join(websiteDir, '..', 'assets', 'StoreAssets', 'RepositoryOpenGraph.png');
|
|
||||||
if (fs.existsSync(ogSrc)) {
|
|
||||||
await sharp(ogSrc)
|
|
||||||
.png({ quality: 80, palette: true })
|
|
||||||
.toFile(path.join(targetAssetsDir, 'og-image.png'));
|
|
||||||
console.log(' ✓ og-image.png generated from RepositoryOpenGraph.png');
|
|
||||||
} else {
|
|
||||||
console.warn(` ⚠️ Warning: ${ogSrc} not found. Skipping og-image generation.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 0.7 Stage the committed flag-font subset (~45% smaller) ──
|
// ── 0.7 Stage the committed flag-font subset (~45% smaller) ──
|
||||||
console.log('Staging flag font subset...');
|
console.log('Staging flag font subset...');
|
||||||
const flagFontName = stageFlagFontSubset(websiteDir, wwwDir);
|
const flagFontName = stageFlagFontSubset(websiteDir, wwwDir);
|
||||||
@@ -595,6 +538,64 @@ async function compile() {
|
|||||||
console.log(' Assets copied.');
|
console.log(' Assets copied.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 6.1 Generate build-only logo, favicon, and social-preview assets ──
|
||||||
|
// Keep generated files out of website/assets; that directory is source input.
|
||||||
|
console.log('Generating responsive website logos...');
|
||||||
|
const rawLogoSrc = path.join(websiteDir, '..', 'assets', 'icon', 'TwoPointZero_Logo_Icon_600.webp');
|
||||||
|
const targetAssetsDir = destAssets;
|
||||||
|
|
||||||
|
if (fs.existsSync(rawLogoSrc)) {
|
||||||
|
fs.mkdirSync(targetAssetsDir, { recursive: true });
|
||||||
|
|
||||||
|
// Generate NewLogoIcon_64.webp (64x64)
|
||||||
|
await sharp(rawLogoSrc)
|
||||||
|
.resize(64, 64)
|
||||||
|
.toFile(path.join(targetAssetsDir, 'NewLogoIcon_64.webp'));
|
||||||
|
|
||||||
|
// Generate NewLogoIcon_128.webp (128x128)
|
||||||
|
await sharp(rawLogoSrc)
|
||||||
|
.resize(128, 128)
|
||||||
|
.toFile(path.join(targetAssetsDir, 'NewLogoIcon_128.webp'));
|
||||||
|
|
||||||
|
// Generate NewLogoIcon.webp (256x256)
|
||||||
|
await sharp(rawLogoSrc)
|
||||||
|
.resize(256, 256)
|
||||||
|
.toFile(path.join(targetAssetsDir, 'NewLogoIcon.webp'));
|
||||||
|
|
||||||
|
console.log(' ✓ WebP logo variants successfully generated in website/www/assets/');
|
||||||
|
} else {
|
||||||
|
console.warn(` ⚠️ Warning: Source logo ${rawLogoSrc} not found. Skipping auto-generation.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pngMappings = [
|
||||||
|
{ src: 'TwoPointZero_Logo_Icon_16.png', dest: 'favicon-16x16.png' },
|
||||||
|
{ src: 'TwoPointZero_Logo_Icon_32.png', dest: 'favicon-32x32.png' },
|
||||||
|
{ src: 'TwoPointZero_Logo_Icon_256.png', dest: 'apple-touch-icon.png' },
|
||||||
|
{ src: 'TwoPointZero_Logo_Icon_256.png', dest: 'icon-192x192.png' }
|
||||||
|
];
|
||||||
|
for (const mapping of pngMappings) {
|
||||||
|
const srcPath = path.join(websiteDir, '..', 'assets', 'icon', mapping.src);
|
||||||
|
const destPath = path.join(targetAssetsDir, mapping.dest);
|
||||||
|
if (fs.existsSync(srcPath)) {
|
||||||
|
fs.copyFileSync(srcPath, destPath);
|
||||||
|
} else {
|
||||||
|
console.warn(` ⚠️ Warning: Source PNG ${srcPath} not found.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(' ✓ Favicons/touch icons successfully synced to website/www/assets/');
|
||||||
|
|
||||||
|
// Social link preview image (og:image / twitter:image), same artwork as the
|
||||||
|
// GitHub repository OpenGraph card. PNG kept for maximum scraper support.
|
||||||
|
const ogSrc = path.join(websiteDir, '..', 'assets', 'StoreAssets', 'RepositoryOpenGraph.png');
|
||||||
|
if (fs.existsSync(ogSrc)) {
|
||||||
|
await sharp(ogSrc)
|
||||||
|
.png({ quality: 80, palette: true })
|
||||||
|
.toFile(path.join(targetAssetsDir, 'og-image.png'));
|
||||||
|
console.log(' ✓ og-image.png generated from RepositoryOpenGraph.png');
|
||||||
|
} else {
|
||||||
|
console.warn(` ⚠️ Warning: ${ogSrc} not found. Skipping og-image generation.`);
|
||||||
|
}
|
||||||
|
|
||||||
// Copy apple-touch-icon to www root (browsers/iOS request these at /)
|
// Copy apple-touch-icon to www root (browsers/iOS request these at /)
|
||||||
const appleTouchSrc = path.join(destAssets, 'apple-touch-icon.png');
|
const appleTouchSrc = path.join(destAssets, 'apple-touch-icon.png');
|
||||||
if (fs.existsSync(appleTouchSrc)) {
|
if (fs.existsSync(appleTouchSrc)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user