docs: record the nested cross-origin player investigation

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>
This commit is contained in:
Timo
2026-08-18 20:11:00 +02:00
parent e0c68650c5
commit 488a9a086f
+212
View File
@@ -0,0 +1,212 @@
# 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](#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 `webNavigation` permission. The permission was not
> acceptable for the store listing, so `4d78970` removed 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 `sandbox` attribute**, so it is injectable.
- It has **no `<video>` element at all** until playback starts.
- Its frame **navigates after load** (a `/720p` suffix appears), invalidating `documentId`.
- Unselected mirrors stay in the DOM, collapsed to 0x0.
- Loading `kodikplayer.com` directly 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:
1. **A learned frame registry.** Every `executeScript` result carries `frameId` and
`documentId`, and every content script that messages the background carries
`sender.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.onUpdated` reports
`status: '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.
2. **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 requires `sender.frameId` to 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.
### 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
- `targetReady` is true, so activation completed and the tab selection is intact.
- The error is the raw failure of `chrome.tabs.sendMessage` to the **elected frame**:
nothing is listening there.
- Kodik rebuilds and renavigates its player frame, which invalidates the `documentId` the
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.webNavigation` anywhere, and `permissions` limited 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.