Bug 1956165 - Add a Fenix-only JS webcompat intervention for m.youtube.com to fix picture-in-picture; r=padenot,denschub,webcompat-reviewers,webidl,smaug

Differential Revision: https://phabricator.services.mozilla.com/D245108
This commit is contained in:
Thomas Wisniewski
2025-04-11 07:48:11 +00:00
parent 4ac19ccb48
commit e723146d01
12 changed files with 179 additions and 16 deletions

View File

@@ -3370,5 +3370,22 @@
}
}
]
},
"1956165": {
"label": "m.youtube.com picture-in-picture fix",
"bugs": {
"1956165": {
"issue": "broken-videos",
"matches": ["*://m.youtube.com/*"]
}
},
"interventions": [
{
"platforms": ["fenix"],
"content_scripts": {
"js": ["bug1956165-www.youtube.com-picture-in-picture-fix.js"]
}
}
]
}
}

View File

@@ -10,6 +10,9 @@ this.appConstants = class extends ExtensionAPI {
getAPI() {
return {
appConstants: {
getAndroidPackageName: () => {
return Services.env.get("MOZ_ANDROID_PACKAGE_NAME");
},
getEffectiveUpdateChannel: () => {
const ver = AppConstants.MOZ_APP_VERSION_DISPLAY;
if (ver.includes("a")) {

View File

@@ -3,6 +3,13 @@
"namespace": "appConstants",
"description": "experimental API to expose some app constants",
"functions": [
{
"name": "getAndroidPackageName",
"type": "function",
"description": "",
"async": true,
"parameters": []
},
{
"name": "getEffectiveUpdateChannel",
"type": "function",

View File

@@ -0,0 +1,57 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
/**
* Bug 1956165 - Fix picture-in-picture mode on Mobile YouTube
*
* YouTube does not play well with our picture in picture implementation, and
* effectively cancels it. We can work around this conflict with this site patch.
*/
/* globals exportFunction */
console.info(
"exitFullscreen and pause have been overridden for compatibility reasons. See https://bugzilla.mozilla.org/show_bug.cgi?id=1956165 for details."
);
const originalPause = window.wrappedJSObject.HTMLMediaElement.prototype.pause;
const newPause = exportFunction(function (...args) {
if (this.ownerDocument.inAndroidPipMode) {
return undefined;
}
return originalPause.apply(this, args);
}, window);
Object.defineProperty(
window.wrappedJSObject.HTMLMediaElement.prototype,
"pause",
{
value: newPause,
writable: true,
configurable: true,
}
);
const originalExitFullscreen =
window.wrappedJSObject.Document.prototype.exitFullscreen;
const newExitFullscreen = exportFunction(function (...args) {
if (!this.ownerDocument.inAndroidPipMode) {
return undefined;
}
return originalExitFullscreen.apply(this, args);
}, window);
Object.defineProperty(
window.wrappedJSObject.Document.prototype,
"exitFullscreen",
{
value: newExitFullscreen,
writable: true,
configurable: true,
}
);

View File

@@ -280,7 +280,15 @@ var InterventionHelpers = {
},
},
valid_platforms: ["all", "android", "desktop", "linux", "mac", "windows"],
valid_platforms: [
"all",
"android",
"desktop",
"fenix",
"linux",
"mac",
"windows",
],
valid_channels: ["beta", "esr", "nightly", "stable"],
shouldSkip(intervention, firefoxVersion, firefoxChannel) {
@@ -329,6 +337,12 @@ var InterventionHelpers = {
platformInfo.os,
platformInfo.os == "android" ? "android" : "desktop",
];
if (platformInfo.os == "android") {
const packageName = await browser.appConstants.getAndroidPackageName();
if (packageName.includes("fenix") || packageName.includes("firefox")) {
InterventionHelpers._platformMatches.push("fenix");
}
}
}
return InterventionHelpers._platformMatches;
},

View File

@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "Web Compatibility Interventions",
"description": "Urgent post-release fixes for web compatibility.",
"version": "139.2.0",
"version": "139.3.0",
"browser_specific_settings": {
"gecko": {
"id": "webcompat@mozilla.org",

View File

@@ -1761,7 +1761,8 @@ bool Document::CallerIsTrustedAboutCertError(JSContext* aCx,
#endif
}
bool Document::CallerCanAccessPrivilegeSSA(JSContext* aCx, JSObject* aObject) {
bool Document::CallerIsSystemPrincipalOrWebCompatAddon(JSContext* aCx,
JSObject* aObject) {
RefPtr<BasePrincipal> principal =
BasePrincipal::Cast(nsContentUtils::SubjectPrincipal(aCx));
@@ -1769,13 +1770,12 @@ bool Document::CallerCanAccessPrivilegeSSA(JSContext* aCx, JSObject* aObject) {
return false;
}
// We allow the privilege SSA to be called from system principal.
// We allow the privileged APIs to be called from system principal.
if (principal->IsSystemPrincipal()) {
return true;
}
// We only allow calling the privilege SSA from the content script of the
// webcompat extension.
// We only allow calling privileged APIs from the webcompat extension.
if (auto* policy = principal->ContentScriptAddonPolicy()) {
nsAutoString addonID;
policy->GetId(addonID);

View File

@@ -2268,11 +2268,12 @@ class Document : public nsINode,
static bool CallerIsTrustedAboutCertError(JSContext* aCx, JSObject* aObject);
/**
* This function checks if the privilege storage access api is available for
* the caller. We only allow privilege SSA to be called by system principal
* and webcompat extension.
* This function checks if the caller has access to privileged chrome APIs
* such as the storage access API and inAndroidPipMode. We only allow such
* APIs to be called by system principal and the built-in webcompat addon.
*/
static bool CallerCanAccessPrivilegeSSA(JSContext* aCx, JSObject* aObject);
static bool CallerIsSystemPrincipalOrWebCompatAddon(JSContext* aCx,
JSObject* aObject);
/**
* Get the security info (i.e. certificate validity, errorCode, etc) for a

View File

@@ -388,7 +388,7 @@ partial interface Document {
// Whether we're in android's Picture-in-Picture mode.
// Top level document only (for now, if we want to deal with iframes, please
// also fix bug 1959448 while at it).
[ChromeOnly]
[Func="Document::CallerIsSystemPrincipalOrWebCompatAddon"]
readonly attribute boolean inAndroidPipMode;
// The principal to use for the storage area of this document
@@ -559,7 +559,7 @@ partial interface Document {
// webcompat extension the ability to request the storage access for a given
// third party.
partial interface Document {
[Func="Document::CallerCanAccessPrivilegeSSA", NewObject]
[Func="Document::CallerIsSystemPrincipalOrWebCompatAddon", NewObject]
Promise<undefined> requestStorageAccessForOrigin(DOMString thirdPartyOrigin, optional boolean requireUserInteraction = true);
};

View File

@@ -328,7 +328,10 @@ async def session(driver, test_config):
yield session
await session.bidi_session.end()
session.end()
try:
session.end()
except webdriver.error.UnknownErrorException:
pass
@pytest.fixture(autouse=True)
@@ -398,10 +401,11 @@ def only_firefox_versions(bug_number, firefox_version, request):
@pytest.fixture(autouse=True)
def only_platforms(bug_number, platform, request, session):
is_fenix = "org.mozilla.fenix" in session.capabilities.get("moz:profile", "")
if request.node.get_closest_marker("only_platforms"):
plats = request.node.get_closest_marker("only_platforms").args
for only in plats:
if only == platform:
if only == platform or (only == "fenix" and is_fenix):
return
pytest.skip(
f"Bug #{bug_number} skipped on platform ({platform}, test only for {' or '.join(plats)})"
@@ -410,10 +414,11 @@ def only_platforms(bug_number, platform, request, session):
@pytest.fixture(autouse=True)
def skip_platforms(bug_number, platform, request, session):
is_fenix = "org.mozilla.fenix" in session.capabilities.get("moz:profile", "")
if request.node.get_closest_marker("skip_platforms"):
plats = request.node.get_closest_marker("skip_platforms").args
for skipped in plats:
if skipped == platform:
if skipped == platform or (skipped == "fenix" and is_fenix):
pytest.skip(
f"Bug #{bug_number} skipped on platform ({platform}, test skipped for {' and '.join(plats)})"
)

View File

@@ -2,7 +2,7 @@
console_output_style = classic
markers =
only_firefox_versions: only run tests on specific firefox versions (specify min and/or max)
only_platforms: only run tests on specific platforms (mac, linux, windows, android)
only_platforms: only run tests on specific platforms (mac, linux, windows, android, fenix)
skip_platforms: skip tests on specific platforms (mac, linux, windows, android)
only_channels: only run tests on specific channels (beta, esr, nightly, stable)
skip_channels: skip tests on specific channels (beta, esr, nightly, stable)

View File

@@ -0,0 +1,59 @@
import asyncio
import pytest
URL = "https://m.youtube.com/results?search_query=trailer"
FIRST_VIDEO_CSS = "a[href*=watch]"
FULLSCREEN_ICON_CSS = "button.fullscreen-icon"
PLAY_BUTTON_CSS = ".ytp-large-play-button.ytp-button"
async def pip_activates_properly(client):
await client.make_preload_script("delete navigator.__proto__.webdriver")
await client.navigate(URL)
client.await_css(FIRST_VIDEO_CSS, is_displayed=True).click()
# wait for the video to start playing
client.execute_async_script(
"""
const done = arguments[0];
const i = setInterval(() => {
const vid = document.querySelector("video");
if (vid && !vid.paused) {
done();
}
}, 100);
"""
)
client.soft_click(client.await_css(FULLSCREEN_ICON_CSS, is_displayed=True))
await asyncio.sleep(1)
with client.using_context("chrome"):
client.execute_script(
"""
ChromeUtils.androidMoveTaskToBack();
"""
)
await asyncio.sleep(1)
return client.execute_script(
"""
return !(document.querySelector("video")?.paused);
"""
)
@pytest.mark.only_platforms("fenix")
@pytest.mark.asyncio
@pytest.mark.with_interventions
async def test_enabled(client):
assert await pip_activates_properly(client)
@pytest.mark.only_platforms("fenix")
@pytest.mark.asyncio
@pytest.mark.without_interventions
async def test_disabled(client):
assert not await pip_activates_properly(client)