Why removing webNavigation broke Drive and the anime hosts, the verified frame layout of the live KODIK page, each defect that was fixed on the way from tab selection to playback control, and how the frame list is reconstructed without the permission. Also records the one defect still open: an elected frame that the player has since torn down is never released, because the guarded refresh reports "unchanged" when no video is reachable and adoption has already set hasVideo, so nothing moves the target back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 KiB
Targeting a nested cross-origin player without webNavigation (open)
Status: Largely fixed, one confirmed defect still open — see Open: a dead frame election is never released. Everything under Fixed was verified against the live site or a fixture rebuilt from it.
Context: v3.1.2 added support for players inside cross-origin frames (Google Drive, anime hosts) and shipped with a
webNavigationpermission. The permission was not acceptable for the store listing, so4d78970removed it. Removing it broke the feature. This document records why, and what replaced it.
Why the permission mattered
chrome.webNavigation.getAllFrames({ tabId }) is an observation: a browser-side
registry lookup that returns every frame's frameId and documentId without touching
the frames. It answers while a frame is loading, navigating or being rebuilt.
chrome.scripting.executeScript({ target: { tabId, allFrames: true } }) is an
intervention: it must run code inside every frame. It is all-or-nothing — one frame
that is mid-teardown makes Chromium reject the whole call — and it only reports frames it
managed to enter.
That difference is the entire regression. The players on these sites renavigate and
rebuild their <video> continuously, so an intervention-based discovery reliably lands in
a window where it fails, while an observation-based one never had such a window.
No permission is needed to close the gap — see Replacing the frame list below.
The page under test
yummyanime.tv, KODIK mirror selected. Verified live:
top (yummyanime.tv) 0 videos
├── yastatic share iframe 0x0 hidden
├── xfplayer_<id> 0x0 hidden, same-origin <- parked mirror
│ └── absciss.thealloha.club 0x0 hidden, cross-origin
├── xfplayer_<id> 830x498 visible, same-origin <- active wrapper
│ └── kodikplayer.com 830x498 visible, cross-origin <- the player
└── youtube.com/embed 0x0 hidden, cross-origin <- another mirror
Properties that matter, all confirmed:
- The player is at depth 2, behind a same-origin wrapper.
- It carries no
sandboxattribute, so it is injectable. - It has no
<video>element at all until playback starts. - Its frame navigates after load (a
/720psuffix appears), invalidatingdocumentId. - Unselected mirrors stay in the DOM, collapsed to 0x0.
- Loading
kodikplayer.comdirectly fails a referrer check, so it can only be inspected embedded.
The YouTube mirror works where the others do not, because it is a direct child of the top frame; the failures are specific to depth-2 nesting.
Fixed
Each item was a separate defect on the path from "user picks a tab" to "playback is controlled".
Access was inferred, not measured. Every probe error was swallowed, and any origin
that failed to answer was reported as missing host access — so a slow player frame
produced a permission prompt for an origin the extension already held. The resolver now
calls permissions.contains() before raising an access error.
Probes were unbounded. Nine executeScript calls in the injection path had no
timeout; one unresponsive frame pinned activeTargetActivation and the popup showed
activating forever with nothing in the log. All are time-boxed, and a watchdog abandons
any activation still running after 30s.
Equally-ranked players were a hard failure. Several mirrors loaded at once is an ordinary layout here. The resolver now holds the top frame and waits for one of them to start playing, which breaks the tie.
Inconclusive probes moved the target. A page whose players are still loading resolves differently from call to call, and every difference triggered a full teardown and reinjection, so activation never settled. A probe that finds no video now leaves the target alone.
The video-state poll restarted the target. getReadyTabVideoState() treated "no video
found" as a broken injection and forced a reactivation — on every poll, and the dev panel
polls on a timer.
Ordinary playback looked like a layout change. The media-frame monitor's candidate
signature included paused/readyState/duration, so every play, pause and buffering
tick retriggered a full reactivation.
A hidden srcless iframe could hide the page containing it. An <iframe> without src
resolves to its parent document's URL. Ad slots are frequently srcless, so a hidden slot
could mark a real player's frame as hidden.
The chat overlay followed the player into its frame, rendering on top of the video and scoping close/minimize to that frame. It is now always installed in the top document.
A failed activation discarded the user's selection, so the popup came back empty after
being reopened. The selection is now stored in its own right with a terminal state
(ready / activating / access_required / error).
Replacing the frame list
Two mechanisms together do what getAllFrames() did, with no permission:
-
A learned frame registry. Every
executeScriptresult carriesframeIdanddocumentId, and every content script that messages the background carriessender.frameId. Those are recorded per tab (capped, top frame never evicted) and any frame the sweep missed is then probed individually, so one rejected probe costs one frame instead of the whole page.The registry must not be cleared on navigation:
tabs.onUpdatedreportsstatus: 'loading'for same-document History API navigations too, which is exactly what these sites do when switching mirror or episode part. Clearing there wiped the registry while the player frame was being built. It self-corrects instead — a probe that reached more than the top frame supersedes the stored ids. -
Not needing the answer. Frame election is the fragile half, and playback does not depend on it:
- Outbound, a command is broadcast to the tab when the elected frame reports no
video. Every content-script handler starts with
findVideo()and returns when there is none, so only the frame that owns the player acts. - Inbound,
isCurrentContentSender()no longer requiressender.frameIdto equal the elected frame while that frame has no video — otherwise the user's own play/pause arriving from the real player frame was discarded as a stale sender. The reporting frame is then adopted as the target.
Both relaxations apply only while the elected frame reports no video; a good election still takes the strict path, so hidden-player rejection is unaffected.
- Outbound, a command is broadcast to the tab when the elected frame reports no
video. Every content-script handler starts with
Latency
Selection felt broken at ~20s. Measured against the two anime fixtures after the fix, calm page / heavy ad churn:
| calm | ad churn | |
|---|---|---|
| selection | 2.5s | 2.6s |
| player promotion | 0.34s | 2.7s |
What was removed: the visibility handshake ran even when no frame had a video (its only job is ranking candidates); its pass count was fixed at the worst-case nesting depth of four rather than the observed two; the retry budget was spent waiting for a video no frame had; and both probe timeouts were 2000ms for what is synchronous DOM work — a live frame answers in tens of milliseconds, anything slower is a frame being torn down.
The remaining ~2.5s is the injection chain itself (monitors, page-API bridge, chat overlay, content script), not discovery.
Open: a dead frame election is never released
Reported: 2026-08-18, against the build at e0c6865.
Symptom in the dev panel:
Kommunikation mit dem Tab-Video fehlgeschlagen.
(Could not establish connection. Receiving end does not exist.)
with targetReady: true, Video Count: 0, In Iframe: NO, and no activation errors in
the log.
What is verified
targetReadyis true, so activation completed and the tab selection is intact.- The error is the raw failure of
chrome.tabs.sendMessageto the elected frame: nothing is listening there. - Kodik rebuilds and renavigates its player frame, which invalidates the
documentIdthe election is pinned to.
Root cause (verified by reading, not yet by instrumentation)
getReadyTabVideoState() recovers via
refreshCurrentMediaTarget(tabId, { onlyIfTargetMoved: true }). On a page with no
reachable video, selectedMediaTargetMoved() returns false, the refresh reports
unchanged, and the stale currentTargetFrameId / currentTargetDocumentId are kept.
The second read fails identically and the error is returned.
adoptReportingFrame() compounds it: adoption sets currentTargetHasVideo = true, and
once that is true selectedMediaTargetMoved() only moves on a frame/document change. If
the adopted frame then dies, there is no path back.
Intended fix
When the content script in the elected frame is provably unreachable
(Receiving end does not exist, No document with id), the election is invalid: reset
the frame election to the top frame with hasVideo: false — never the tab selection —
so ordinary discovery and adoption can find the player again. Apply it in both places that
see the error: getReadyTabVideoState() and the connection-error branch of
_routeToContentInternal().
Guardrails
Contract tests in extension/target-tab-lifecycle.test.mjs hold the invariants that were
each violated at least once during this work:
- no
chrome.webNavigationanywhere, andpermissionslimited to the v3.1.1 set - the frame registry is bounded and is not cleared on navigation
- monitors are injected and deactivated across known frames, not only via the sweep
- forcing a rebuild stays rare: only an unreachable content script, an explicit request, and a completed navigation
- playback state stays out of the monitor's candidate signature
- chat messages never go to the media frame
Browser-level coverage lives in tests/e2e/extension.spec.mjs against fixtures rebuilt
from the real page: yummy-style-player.html (player present up front),
yummy-deferred-player.html (player built on play) and yummy-churning-player.html
(live ad churn), plus drive-style-player.html for the chat-placement case.