diff --git a/web/packages/extension/assets/_locales/en/messages.json b/web/packages/extension/assets/_locales/en/messages.json index c69d8f0f0b51..a157df2d4252 100644 --- a/web/packages/extension/assets/_locales/en/messages.json +++ b/web/packages/extension/assets/_locales/en/messages.json @@ -11,6 +11,9 @@ "settings_autostart": { "message": "Autoplay Flash content (click to unmute)" }, + "settings_swf_takeover": { + "message": "Play SWF files in browser instead of downloading" + }, "settings_log_level": { "message": "Log level" }, @@ -108,7 +111,7 @@ "message": "Permanently enable for this site" }, "permissions_explanation": { - "message": "The Ruffle extension works best with the ability to access data on all websites. This allows it to run when any website first loads, therefore allowing it to convince websites that do Flash Player detection immediately to let the Flash content run. It also gives it the ability to load SWF files into its internal player page from other locations on the web. If you do not grant this permission, you will need to enable Ruffle manually on every website on which you wish to use it by clicking the icon for the extension and enabling it for that website." + "message": "The Ruffle extension works best with the ability to access data on all websites. This allows it to run when any website first loads, therefore allowing it to convince websites that do Flash Player detection immediately to let the Flash content run. It also gives it the ability to load SWF files into its internal player page from other locations on the web and allows it to automatically load direct SWF links into that page. If you do not grant this permission, you will need to enable Ruffle manually on every website on which you wish to use it by clicking the icon for the extension and enabling it for that website." }, "swf_player_permissions": { "message": "You must grant permission to this URL origin to load SWFs from it." diff --git a/web/packages/extension/assets/css/options.css b/web/packages/extension/assets/css/options.css index 07906a6e6b1d..e199d4c77eff 100644 --- a/web/packages/extension/assets/css/options.css +++ b/web/packages/extension/assets/css/options.css @@ -1,3 +1,7 @@ +.hidden { + display: none; +} + .logo { max-width: 224px; } diff --git a/web/packages/extension/assets/options.html b/web/packages/extension/assets/options.html index f803c90ff9af..273683723c7a 100644 --- a/web/packages/extension/assets/options.html +++ b/web/packages/extension/assets/options.html @@ -29,6 +29,10 @@ +
+ + +
Advanced Options
diff --git a/web/packages/extension/assets/popup.html b/web/packages/extension/assets/popup.html index 34a833807256..05ffc82e6b1e 100644 --- a/web/packages/extension/assets/popup.html +++ b/web/packages/extension/assets/popup.html @@ -32,6 +32,10 @@
+
+ + +
diff --git a/web/packages/extension/manifest.json5 b/web/packages/extension/manifest.json5 index 666b4c4216e1..354998c4d4c6 100644 --- a/web/packages/extension/manifest.json5 +++ b/web/packages/extension/manifest.json5 @@ -43,7 +43,7 @@ "page": "options.html", "open_in_tab": true, }, - "host_permissions": [""], // To allow script injecting + the internal player to bypass CORS + "host_permissions": [""], // To allow script injecting + the internal player to bypass CORS + SWF takeover "permissions": [ "storage", "scripting", diff --git a/web/packages/extension/src/background.ts b/web/packages/extension/src/background.ts index 9509468dccca..c9312bb180c8 100644 --- a/web/packages/extension/src/background.ts +++ b/web/packages/extension/src/background.ts @@ -8,7 +8,164 @@ async function contentScriptRegistered() { return matchingScripts?.length > 0; } +// Copied from https://github.com/w3c/webextensions/issues/638#issuecomment-2181124486 +async function isHeaderConditionSupported() { + let needCleanup = false; + const ruleId = 4; + try { + // Throws synchronously if not supported. + await utils.declarativeNetRequest.updateDynamicRules({ + addRules: [ + { + id: ruleId, + condition: { responseHeaders: [{ header: "whatever" }] }, + action: { + type: + chrome.declarativeNetRequest.RuleActionType + ?.BLOCK ?? "block", + }, + }, + ], + }); + needCleanup = true; + } catch { + return false; // responseHeaders condition not supported. + } + // Chrome may recognize the properties but have the implementation behind a flag. + // When the implementation is disabled, validation is skipped too. + try { + await utils.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: [ruleId], + addRules: [ + { + id: ruleId, + condition: { responseHeaders: [] }, + action: { + type: + chrome.declarativeNetRequest.RuleActionType + ?.BLOCK ?? "block", + }, + }, + ], + }); + needCleanup = true; + return false; // Validation skipped = feature disabled. + } catch { + return true; // Validation worked = feature enabled. + } finally { + if (needCleanup) { + await utils.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: [ruleId], + }); + } + } +} + +async function enableSWFTakeover() { + // Checks if the responseHeaders condition is supported and not behind a disabled flag. + if (utils.declarativeNetRequest && (await isHeaderConditionSupported())) { + const { ruffleEnable } = await utils.getOptions(); + if (ruffleEnable) { + const playerPage = utils.runtime.getURL("/player.html"); + const rules = [ + { + id: 1, + action: { + type: + chrome.declarativeNetRequest.RuleActionType + ?.REDIRECT ?? "redirect", + redirect: { regexSubstitution: playerPage + "#\\0" }, + }, + condition: { + regexFilter: ".*", + responseHeaders: [ + { + header: "content-type", + values: [ + "application/x-shockwave-flash", + "application/futuresplash", + "application/x-shockwave-flash2-preview", + "application/vnd.adobe.flash.movie", + ], + }, + ], + resourceTypes: [ + chrome.declarativeNetRequest.ResourceType + ?.MAIN_FRAME ?? "main_frame", + ], + }, + }, + { + id: 2, + action: { + type: + chrome.declarativeNetRequest.RuleActionType + ?.REDIRECT ?? "redirect", + redirect: { regexSubstitution: playerPage + "#\\0" }, + }, + condition: { + regexFilter: + "^.*:\\/\\/.*\\/.*\\.s(?:wf|pl)(\\?.*|#.*|)$", + responseHeaders: [ + { + header: "content-type", + values: [ + "application/octet-stream", + "application/binary-stream", + "", + ], + }, + ], + resourceTypes: [ + chrome.declarativeNetRequest.ResourceType + ?.MAIN_FRAME ?? "main_frame", + ], + }, + }, + { + id: 3, + action: { + type: + chrome.declarativeNetRequest.RuleActionType + ?.REDIRECT ?? "redirect", + redirect: { regexSubstitution: playerPage + "#\\0" }, + }, + condition: { + regexFilter: + "^.*:\\/\\/.*\\/.*\\.s(?:wf|pl)(\\?.*|#.*|)$", + excludedResponseHeaders: [{ header: "content-type" }], + resourceTypes: [ + chrome.declarativeNetRequest.ResourceType + ?.MAIN_FRAME ?? "main_frame", + ], + }, + }, + ]; + await utils.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: [1, 2, 3], + addRules: rules, + }); + } + } else { + utils.storage.sync.set({ responseHeadersUnsupported: true }); + } +} + +async function disableSWFTakeover() { + if (utils.declarativeNetRequest && (await isHeaderConditionSupported())) { + await utils.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: [1, 2, 3], + }); + } else { + utils.storage.sync.set({ responseHeadersUnsupported: true }); + } +} + async function enable() { + const { swfTakeover } = await utils.getOptions(); + if (swfTakeover) { + await enableSWFTakeover(); + } if ( !utils.scripting || (utils.scripting.ExecutionWorld && !utils.scripting.ExecutionWorld.MAIN) @@ -57,6 +214,7 @@ async function disable() { ids: ["plugin-polyfill", "4399"], }); } + await disableSWFTakeover(); } async function onAdded(permissions: chrome.permissions.Permissions) { @@ -106,6 +264,13 @@ utils.storage.onChanged.addListener(async (changes, namespace) => { await disable(); } } + if (namespace === "sync" && "swfTakeover" in changes) { + if (changes["swfTakeover"]!.newValue) { + await enableSWFTakeover(); + } else { + await disableSWFTakeover(); + } + } }); async function handleInstalled(details: chrome.runtime.InstalledDetails) { diff --git a/web/packages/extension/src/common.ts b/web/packages/extension/src/common.ts index ff6e91b2351f..b49d4332caa1 100644 --- a/web/packages/extension/src/common.ts +++ b/web/packages/extension/src/common.ts @@ -6,6 +6,7 @@ export interface Options extends BaseLoadOptions { ignoreOptout: boolean; autostart: boolean; showReloadButton: boolean; + swfTakeover: boolean; } interface OptionElement { diff --git a/web/packages/extension/src/options.ts b/web/packages/extension/src/options.ts index 452bfb207e42..4257116dc35c 100644 --- a/web/packages/extension/src/options.ts +++ b/web/packages/extension/src/options.ts @@ -2,7 +2,15 @@ import * as utils from "./utils"; import { bindOptions, resetOptions } from "./common"; import { buildInfo } from "ruffle-core"; -window.addEventListener("DOMContentLoaded", () => { +window.addEventListener("DOMContentLoaded", async () => { + const data = await utils.storage.sync.get({ + responseHeadersUnsupported: false, + }); + if (data["responseHeadersUnsupported"]) { + document + .getElementById("swf_takeover")! + .parentElement!.classList.add("hidden"); + } document.title = utils.i18n.getMessage("settings_page"); { const vt = document.getElementById("version-text")!; diff --git a/web/packages/extension/src/player.ts b/web/packages/extension/src/player.ts index 1db227394595..7daeb91837d3 100644 --- a/web/packages/extension/src/player.ts +++ b/web/packages/extension/src/player.ts @@ -42,6 +42,7 @@ const baseExtensionConfig = { letterbox: "on" as Letterbox, forceScale: true, forceAlign: true, + showSwfDownload: true, }; const swfToFlashVersion: { [key: number]: string } = { diff --git a/web/packages/extension/src/popup.ts b/web/packages/extension/src/popup.ts index 3daa16a16e96..e82fe68e1589 100644 --- a/web/packages/extension/src/popup.ts +++ b/web/packages/extension/src/popup.ts @@ -155,6 +155,14 @@ async function displayTabStatus() { } window.addEventListener("DOMContentLoaded", async () => { + const data = await utils.storage.sync.get({ + responseHeadersUnsupported: false, + }); + if (data["responseHeadersUnsupported"]) { + document + .getElementById("swf_takeover")! + .parentElement!.classList.add("hidden"); + } bindOptions((options) => { savedOptions = options; optionsChanged(); diff --git a/web/packages/extension/src/utils.ts b/web/packages/extension/src/utils.ts index 451c46db252b..a0d72cdd9a44 100644 --- a/web/packages/extension/src/utils.ts +++ b/web/packages/extension/src/utils.ts @@ -7,8 +7,13 @@ const DEFAULT_OPTIONS: Required = { ignoreOptout: false, autostart: false, showReloadButton: false, + swfTakeover: true, }; +// TODO: Once https://crbug.com/798169 is addressed, just use browser. +// We have to wait until whatever version of Chromium supports that +// is old enough to be the oldest version we want to support. + export let i18n: typeof browser.i18n | typeof chrome.i18n; type ScriptingType = (typeof browser.scripting | typeof chrome.scripting) & { @@ -28,6 +33,10 @@ export let runtime: typeof browser.runtime | typeof chrome.runtime; export let permissions: typeof browser.permissions | typeof chrome.permissions; +export let declarativeNetRequest: + | typeof browser.declarativeNetRequest + | typeof chrome.declarativeNetRequest; + function promisify( func: (callback: (result: T) => void) => void, ): Promise { @@ -50,6 +59,7 @@ if (typeof browser !== "undefined") { tabs = browser.tabs; runtime = browser.runtime; permissions = browser.permissions; + declarativeNetRequest = browser.declarativeNetRequest; } else if (typeof chrome !== "undefined") { i18n = chrome.i18n; scripting = chrome.scripting as ScriptingType; @@ -57,6 +67,7 @@ if (typeof browser !== "undefined") { tabs = chrome.tabs; runtime = chrome.runtime; permissions = chrome.permissions; + declarativeNetRequest = chrome.declarativeNetRequest; } else { throw new Error("Extension API not found."); }