From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- toolkit/components/extensions/.eslintrc.js | 239 ++ .../components/extensions/ConduitsChild.sys.mjs | 216 ++ .../components/extensions/ConduitsParent.sys.mjs | 487 +++ toolkit/components/extensions/DocumentObserver.h | 59 + toolkit/components/extensions/Extension.sys.mjs | 4088 ++++++++++++++++++++ .../components/extensions/ExtensionActions.sys.mjs | 667 ++++ .../extensions/ExtensionActivityLog.sys.mjs | 118 + .../components/extensions/ExtensionChild.sys.mjs | 1025 +++++ .../extensions/ExtensionChildDevToolsUtils.sys.mjs | 111 + .../components/extensions/ExtensionCommon.sys.mjs | 3082 +++++++++++++++ .../components/extensions/ExtensionContent.sys.mjs | 1308 +++++++ toolkit/components/extensions/ExtensionDNR.sys.mjs | 2436 ++++++++++++ .../extensions/ExtensionDNRLimits.sys.mjs | 59 + .../extensions/ExtensionDNRStore.sys.mjs | 1700 ++++++++ .../extensions/ExtensionPageChild.sys.mjs | 510 +++ .../components/extensions/ExtensionParent.sys.mjs | 2300 +++++++++++ .../extensions/ExtensionPermissionMessages.sys.mjs | 95 + .../extensions/ExtensionPermissions.sys.mjs | 804 ++++ .../extensions/ExtensionPolicyService.cpp | 762 ++++ .../components/extensions/ExtensionPolicyService.h | 145 + .../extensions/ExtensionPreferencesManager.sys.mjs | 712 ++++ .../extensions/ExtensionProcessScript.sys.mjs | 525 +++ .../extensions/ExtensionScriptingStore.sys.mjs | 351 ++ .../extensions/ExtensionSettingsStore.sys.mjs | 681 ++++ .../extensions/ExtensionShortcuts.sys.mjs | 513 +++ .../components/extensions/ExtensionStorage.sys.mjs | 573 +++ .../extensions/ExtensionStorageIDB.sys.mjs | 878 +++++ .../extensions/ExtensionStorageSync.sys.mjs | 201 + .../extensions/ExtensionStorageSyncKinto.sys.mjs | 1386 +++++++ .../extensions/ExtensionTelemetry.sys.mjs | 343 ++ .../extensions/ExtensionTestCommon.sys.mjs | 677 ++++ .../components/extensions/ExtensionUtils.sys.mjs | 349 ++ .../extensions/ExtensionWorkerChild.sys.mjs | 818 ++++ .../extensions/ExtensionXPCShellUtils.sys.mjs | 780 ++++ toolkit/components/extensions/ExtensionsChild.cpp | 73 + toolkit/components/extensions/ExtensionsChild.h | 37 + toolkit/components/extensions/ExtensionsParent.cpp | 126 + toolkit/components/extensions/ExtensionsParent.h | 58 + toolkit/components/extensions/FindContent.sys.mjs | 250 ++ toolkit/components/extensions/MatchGlob.h | 113 + toolkit/components/extensions/MatchPattern.cpp | 777 ++++ toolkit/components/extensions/MatchPattern.h | 403 ++ .../components/extensions/MatchURLFilters.sys.mjs | 174 + .../components/extensions/MessageChannel.sys.mjs | 1168 ++++++ .../extensions/MessageManagerProxy.sys.mjs | 212 + .../components/extensions/NativeManifests.sys.mjs | 173 + .../components/extensions/NativeMessaging.sys.mjs | 391 ++ toolkit/components/extensions/PExtensions.ipdl | 61 + .../extensions/ProxyChannelFilter.sys.mjs | 427 ++ toolkit/components/extensions/Schemas.sys.mjs | 3942 +++++++++++++++++++ .../extensions/WebExtensionContentScript.h | 216 ++ .../components/extensions/WebExtensionPolicy.cpp | 1137 ++++++ toolkit/components/extensions/WebExtensionPolicy.h | 420 ++ .../components/extensions/WebNavigation.sys.mjs | 400 ++ .../extensions/WebNavigationFrames.sys.mjs | 90 + toolkit/components/extensions/child/.eslintrc.js | 11 + .../extensions/child/ext-backgroundPage.js | 40 + .../extensions/child/ext-contentScripts.js | 76 + .../extensions/child/ext-declarativeNetRequest.js | 35 + .../components/extensions/child/ext-extension.js | 78 + .../components/extensions/child/ext-identity.js | 84 + toolkit/components/extensions/child/ext-runtime.js | 143 + .../components/extensions/child/ext-scripting.js | 49 + toolkit/components/extensions/child/ext-storage.js | 368 ++ toolkit/components/extensions/child/ext-test.js | 371 ++ toolkit/components/extensions/child/ext-toolkit.js | 84 + .../extensions/child/ext-userScripts-content.js | 408 ++ .../components/extensions/child/ext-userScripts.js | 192 + .../components/extensions/child/ext-webRequest.js | 119 + toolkit/components/extensions/components.conf | 16 + toolkit/components/extensions/docs/background.rst | 133 + toolkit/components/extensions/docs/basics.rst | 275 ++ toolkit/components/extensions/docs/events.rst | 609 +++ toolkit/components/extensions/docs/functions.rst | 201 + .../docs/generate_webidl_from_jsonschema.rst | 94 + ...rate_webidl_from_jsonschema_dataflow.drawio.svg | 4 + toolkit/components/extensions/docs/incognito.rst | 78 + toolkit/components/extensions/docs/index.rst | 33 + toolkit/components/extensions/docs/lifecycle.rst | 60 + toolkit/components/extensions/docs/manifest.rst | 68 + toolkit/components/extensions/docs/other.rst | 140 + toolkit/components/extensions/docs/reference.rst | 35 + toolkit/components/extensions/docs/schema.rst | 145 + .../components/extensions/docs/webext-storage.rst | 227 ++ .../components/extensions/docs/webidl_bindings.rst | 246 ++ ..._backgroundWorker_apiRequestHandling.drawio.svg | 4 + .../docs/wiring_up_new_webidl_bindings.rst | 165 + toolkit/components/extensions/dummy.xhtml | 6 + .../components/extensions/ext-browser-content.js | 275 ++ toolkit/components/extensions/ext-toolkit.json | 198 + .../components/extensions/extIWebNavigation.idl | 34 + .../extensions/extensionProcessScriptLoader.js | 11 + .../extensions/extensions-toolkit.manifest | 13 + toolkit/components/extensions/jar.mn | 65 + toolkit/components/extensions/metrics.yaml | 708 ++++ toolkit/components/extensions/moz.build | 148 + .../extensions/mozIExtensionAPIRequestHandling.idl | 192 + .../extensions/mozIExtensionProcessScript.idl | 21 + toolkit/components/extensions/parent/.eslintrc.js | 32 + .../extensions/parent/ext-activityLog.js | 38 + toolkit/components/extensions/parent/ext-alarms.js | 161 + .../extensions/parent/ext-backgroundPage.js | 1116 ++++++ .../extensions/parent/ext-browserSettings.js | 592 +++ .../extensions/parent/ext-browsingData.js | 405 ++ .../extensions/parent/ext-captivePortal.js | 158 + .../components/extensions/parent/ext-clipboard.js | 87 + .../extensions/parent/ext-contentScripts.js | 232 ++ .../extensions/parent/ext-contextualIdentities.js | 362 ++ .../components/extensions/parent/ext-cookies.js | 696 ++++ .../extensions/parent/ext-declarativeNetRequest.js | 169 + toolkit/components/extensions/parent/ext-dns.js | 87 + .../components/extensions/parent/ext-downloads.js | 1261 ++++++ .../components/extensions/parent/ext-extension.js | 25 + .../extensions/parent/ext-geckoProfiler.js | 191 + toolkit/components/extensions/parent/ext-i18n.js | 46 + .../components/extensions/parent/ext-identity.js | 152 + toolkit/components/extensions/parent/ext-idle.js | 113 + .../components/extensions/parent/ext-management.js | 354 ++ .../extensions/parent/ext-networkStatus.js | 85 + .../extensions/parent/ext-notifications.js | 188 + .../extensions/parent/ext-permissions.js | 191 + .../components/extensions/parent/ext-privacy.js | 516 +++ .../extensions/parent/ext-protocolHandlers.js | 100 + toolkit/components/extensions/parent/ext-proxy.js | 335 ++ .../components/extensions/parent/ext-runtime.js | 310 ++ .../components/extensions/parent/ext-scripting.js | 365 ++ .../components/extensions/parent/ext-storage.js | 366 ++ .../components/extensions/parent/ext-tabs-base.js | 2377 ++++++++++++ .../components/extensions/parent/ext-telemetry.js | 195 + toolkit/components/extensions/parent/ext-theme.js | 529 +++ .../components/extensions/parent/ext-toolkit.js | 130 + .../extensions/parent/ext-userScripts.js | 158 + .../extensions/parent/ext-webNavigation.js | 276 ++ .../components/extensions/parent/ext-webRequest.js | 206 + .../components/extensions/schemas/LICENSE-CHROMIUM | 27 + toolkit/components/extensions/schemas/README.md | 13 + .../extensions/schemas/activity_log.json | 101 + toolkit/components/extensions/schemas/alarms.json | 166 + .../extensions/schemas/browser_action.json | 530 +++ .../extensions/schemas/browser_settings.json | 135 + .../extensions/schemas/browsing_data.json | 419 ++ .../extensions/schemas/captive_portal.json | 80 + .../components/extensions/schemas/clipboard.json | 30 + .../extensions/schemas/content_scripts.json | 106 + .../extensions/schemas/contextual_identities.json | 241 ++ toolkit/components/extensions/schemas/cookies.json | 467 +++ .../schemas/declarative_net_request.json | 785 ++++ toolkit/components/extensions/schemas/dns.json | 82 + .../components/extensions/schemas/downloads.json | 810 ++++ toolkit/components/extensions/schemas/events.json | 324 ++ .../components/extensions/schemas/experiments.json | 119 + .../components/extensions/schemas/extension.json | 200 + .../schemas/extension_protocol_handlers.json | 75 + .../extensions/schemas/extension_types.json | 164 + .../extensions/schemas/geckoProfiler.json | 192 + toolkit/components/extensions/schemas/i18n.json | 139 + .../components/extensions/schemas/identity.json | 219 ++ toolkit/components/extensions/schemas/idle.json | 66 + toolkit/components/extensions/schemas/jar.mn | 54 + .../components/extensions/schemas/management.json | 364 ++ .../components/extensions/schemas/manifest.json | 782 ++++ toolkit/components/extensions/schemas/moz.build | 7 + .../extensions/schemas/native_manifest.json | 60 + .../extensions/schemas/network_status.json | 66 + .../extensions/schemas/notifications.json | 416 ++ .../components/extensions/schemas/page_action.json | 329 ++ .../components/extensions/schemas/permissions.json | 150 + toolkit/components/extensions/schemas/privacy.json | 177 + toolkit/components/extensions/schemas/proxy.json | 210 + toolkit/components/extensions/schemas/runtime.json | 721 ++++ .../components/extensions/schemas/scripting.json | 361 ++ toolkit/components/extensions/schemas/storage.json | 394 ++ .../components/extensions/schemas/telemetry.json | 469 +++ toolkit/components/extensions/schemas/test.json | 206 + toolkit/components/extensions/schemas/theme.json | 457 +++ toolkit/components/extensions/schemas/types.json | 168 + .../extensions/schemas/user_scripts.json | 132 + .../extensions/schemas/user_scripts_content.json | 58 + .../extensions/schemas/web_navigation.json | 573 +++ .../components/extensions/schemas/web_request.json | 1475 +++++++ .../storage/ExtensionStorageComponents.h | 40 + .../storage/ExtensionStorageComponents.sys.mjs | 118 + .../components/extensions/storage/components.conf | 22 + toolkit/components/extensions/storage/moz.build | 33 + .../storage/mozIExtensionStorageArea.idl | 127 + .../storage/webext_storage_bridge/Cargo.toml | 25 + .../storage/webext_storage_bridge/src/area.rs | 484 +++ .../storage/webext_storage_bridge/src/error.rs | 124 + .../storage/webext_storage_bridge/src/lib.rs | 65 + .../storage/webext_storage_bridge/src/punt.rs | 321 ++ .../storage/webext_storage_bridge/src/store.rs | 136 + .../extensions/test/browser/.eslintrc.js | 11 + .../test/browser/browser-serviceworker.toml | 9 + .../extensions/test/browser/browser.toml | 103 + .../browser_ext_background_serviceworker.js | 285 ++ ...r_ext_background_serviceworker_pref_disabled.js | 126 + .../test/browser/browser_ext_downloads_filters.js | 139 + .../test/browser/browser_ext_downloads_referrer.js | 91 + ...rowser_ext_eventpage_disableResetIdleForTest.js | 83 + .../browser_ext_extension_page_tab_navigated.js | 226 ++ .../test/browser/browser_ext_management_themes.js | 177 + .../browser/browser_ext_process_crash_handling.js | 180 + .../test/browser/browser_ext_test_mock.js | 47 + ..._ext_themes_additional_backgrounds_alignment.js | 88 + .../browser_ext_themes_alpha_accentcolor.js | 30 + .../test/browser/browser_ext_themes_arrowpanels.js | 82 + .../browser_ext_themes_autocomplete_popup.js | 173 + .../browser/browser_ext_themes_chromeparity.js | 159 + .../browser_ext_themes_dynamic_getCurrent.js | 203 + .../browser_ext_themes_dynamic_onUpdated.js | 154 + .../browser/browser_ext_themes_dynamic_updates.js | 199 + .../test/browser/browser_ext_themes_experiment.js | 450 +++ .../test/browser/browser_ext_themes_findbar.js | 227 ++ .../browser_ext_themes_getCurrent_differentExt.js | 151 + .../test/browser/browser_ext_themes_highlight.js | 63 + .../test/browser/browser_ext_themes_incognito.js | 77 + .../test/browser/browser_ext_themes_lwtsupport.js | 56 + .../browser_ext_themes_multiple_backgrounds.js | 202 + .../test/browser/browser_ext_themes_ntp_colors.js | 203 + .../browser_ext_themes_ntp_colors_perwindow.js | 240 ++ .../test/browser/browser_ext_themes_pbm.js | 422 ++ .../test/browser/browser_ext_themes_persistence.js | 60 + .../test/browser/browser_ext_themes_reset.js | 112 + .../browser/browser_ext_themes_sanitization.js | 174 + .../test/browser/browser_ext_themes_separators.js | 76 + .../test/browser/browser_ext_themes_sidebars.js | 278 ++ .../browser/browser_ext_themes_static_onUpdated.js | 126 + .../test/browser/browser_ext_themes_tab_line.js | 39 + .../test/browser/browser_ext_themes_tab_loading.js | 49 + .../browser/browser_ext_themes_tab_selected.js | 49 + .../test/browser/browser_ext_themes_tab_text.js | 70 + .../browser/browser_ext_themes_theme_transition.js | 48 + .../browser/browser_ext_themes_toolbar_fields.js | 212 + .../browser_ext_themes_toolbar_fields_focus.js | 107 + .../browser_ext_themes_toolbarbutton_colors.js | 63 + .../browser_ext_themes_toolbarbutton_icons.js | 109 + .../test/browser/browser_ext_themes_toolbars.js | 105 + .../test/browser/browser_ext_themes_warnings.js | 144 + .../browser/browser_ext_thumbnails_bg_extension.js | 94 + .../browser/browser_ext_webNavigation_eventpage.js | 72 + ...browser_ext_webRequest_redirect_mozextension.js | 48 + .../browser/browser_ext_windows_popup_title.js | 133 + .../extensions/test/browser/data/test-download.txt | 1 + .../test/browser/data/test_downloads_referrer.html | 10 + toolkit/components/extensions/test/browser/head.js | 115 + .../extensions/test/browser/head_serviceworker.js | 119 + .../data/extension-with-bg-sw/manifest.json | 11 + .../marionette/data/extension-with-bg-sw/sw.js | 3 + .../test/marionette/manifest-serviceworker.toml | 4 + .../test/marionette/service_worker_testutils.py | 48 + ...nsion_serviceworkers_purged_on_pref_disabled.py | 56 + ...orary_extension_serviceworkers_not_persisted.py | 54 + .../extensions/test/mochitest/.eslintrc.js | 12 + .../extensions/test/mochitest/chrome.toml | 55 + .../test/mochitest/chrome_cleanup_script.js | 65 + .../extensions/test/mochitest/chrome_head.js | 1 + .../test/mochitest/file_WebNavigation_page1.html | 12 + .../test/mochitest/file_WebNavigation_page2.html | 7 + .../test/mochitest/file_WebNavigation_page3.html | 9 + .../test/mochitest/file_WebRequest_page3.html | 10 + .../test/mochitest/file_contains_iframe.html | 13 + .../test/mochitest/file_contains_img.html | 12 + .../mochitest/file_contentscript_activeTab.html | 11 + .../mochitest/file_contentscript_activeTab2.html | 10 + .../test/mochitest/file_contentscript_iframe.html | 10 + .../extensions/test/mochitest/file_green.html | 3 + .../extensions/test/mochitest/file_green_blue.html | 16 + .../extensions/test/mochitest/file_image_bad.png | Bin 0 -> 5401 bytes .../extensions/test/mochitest/file_image_good.png | Bin 0 -> 580 bytes .../extensions/test/mochitest/file_image_great.png | Bin 0 -> 580 bytes .../test/mochitest/file_image_redirect.png | Bin 0 -> 5401 bytes .../extensions/test/mochitest/file_indexedDB.html | 28 + .../test/mochitest/file_language_fr_en.html | 14 + .../test/mochitest/file_language_ja.html | 10 + .../test/mochitest/file_language_tlh.html | 12 + .../extensions/test/mochitest/file_mixed.html | 13 + .../test/mochitest/file_redirect_cors_bypass.html | 30 + .../test/mochitest/file_redirect_data_uri.html | 9 + .../test/mochitest/file_remote_frame.html | 20 + .../extensions/test/mochitest/file_sample.html | 13 + .../extensions/test/mochitest/file_sample.txt | 1 + .../test/mochitest/file_sample.txt^headers^ | 1 + .../extensions/test/mochitest/file_script_bad.js | 3 + .../extensions/test/mochitest/file_script_good.js | 12 + .../test/mochitest/file_script_redirect.js | 3 + .../extensions/test/mochitest/file_script_xhr.js | 9 + .../test/mochitest/file_serviceWorker.html | 16 + .../test/mochitest/file_simple_iframe_worker.html | 26 + .../mochitest/file_simple_sandboxed_frame.html | 23 + .../mochitest/file_simple_sandboxed_subframe.html | 10 + .../test/mochitest/file_simple_sharedworker.js | 11 + .../mochitest/file_simple_webrequest_worker.html | 28 + .../test/mochitest/file_simple_worker.js | 8 + .../extensions/test/mochitest/file_simple_xhr.html | 19 + .../test/mochitest/file_simple_xhr_frame.html | 19 + .../test/mochitest/file_simple_xhr_frame2.html | 23 + .../test/mochitest/file_slowed_document.sjs | 49 + .../test/mochitest/file_streamfilter.txt | 1 + .../extensions/test/mochitest/file_style_bad.css | 3 + .../extensions/test/mochitest/file_style_good.css | 3 + .../test/mochitest/file_style_redirect.css | 3 + .../test/mochitest/file_tabs_permission_page1.html | 10 + .../test/mochitest/file_tabs_permission_page2.html | 11 + .../test/mochitest/file_third_party.html | 21 + .../test/mochitest/file_to_drawWindow.html | 9 + .../file_webNavigation_clientRedirect.html | 9 + ...e_webNavigation_clientRedirect_httpHeaders.html | 8 + ...gation_clientRedirect_httpHeaders.html^headers^ | 1 + .../file_webNavigation_frameClientRedirect.html | 12 + .../file_webNavigation_frameRedirect.html | 12 + .../file_webNavigation_manualSubframe.html | 12 + .../file_webNavigation_manualSubframe_page1.html | 8 + .../file_webNavigation_manualSubframe_page2.html | 7 + .../test/mochitest/file_with_about_blank.html | 10 + .../test/mochitest/file_with_images.html | 10 + .../mochitest/file_with_subframes_and_embed.html | 21 + .../test/mochitest/file_with_xorigin_frame.html | 6 + .../components/extensions/test/mochitest/head.js | 155 + .../extensions/test/mochitest/head_cookies.js | 287 ++ .../test/mochitest/head_notifications.js | 171 + .../test/mochitest/head_unlimitedStorage.js | 45 + .../extensions/test/mochitest/head_webrequest.js | 481 +++ .../components/extensions/test/mochitest/hsts.sjs | 10 + .../test/mochitest/mochitest-common.toml | 594 +++ .../test/mochitest/mochitest-remote.toml | 12 + .../test/mochitest/mochitest-serviceworker.toml | 34 + .../extensions/test/mochitest/mochitest.toml | 15 + .../extensions/test/mochitest/mochitest_console.js | 54 + .../extensions/test/mochitest/oauth.html | 26 + .../extensions/test/mochitest/redirect_auto.sjs | 24 + .../extensions/test/mochitest/redirection.sjs | 6 + .../extensions/test/mochitest/return_headers.sjs | 19 + .../extensions/test/mochitest/serviceWorker.js | 0 .../extensions/test/mochitest/slow_response.sjs | 60 + .../test/mochitest/test_check_startupcache.html | 63 + .../test_chrome_ext_contentscript_data_uri.html | 104 + .../test_chrome_ext_contentscript_telemetry.html | 112 + ...ext_contentscript_unrecognizedprop_warning.html | 80 + .../mochitest/test_chrome_ext_downloads_open.html | 114 + .../test_chrome_ext_downloads_saveAs.html | 259 ++ .../test_chrome_ext_downloads_uniquify.html | 118 + .../mochitest/test_chrome_ext_permissions.html | 172 + .../test_chrome_ext_svg_context_fill.html | 204 + .../test_chrome_ext_trackingprotection.html | 100 + ...est_chrome_ext_webnavigation_resolved_urls.html | 81 + ...st_chrome_ext_webrequest_background_events.html | 96 + ...est_chrome_ext_webrequest_host_permissions.html | 89 + .../test_chrome_ext_webrequest_mozextension.html | 193 + .../test_chrome_native_messaging_paths.html | 58 + .../extensions/test/mochitest/test_ext_action.html | 50 + .../test/mochitest/test_ext_activityLog.html | 390 ++ .../extensions/test/mochitest/test_ext_all_apis.js | 248 ++ .../test/mochitest/test_ext_async_clipboard.html | 401 ++ .../test/mochitest/test_ext_background_canvas.html | 42 + .../test/mochitest/test_ext_background_page.html | 84 + .../mochitest/test_ext_background_page_dpi.html | 46 + .../test_ext_browserAction_getUserSettings.html | 50 + .../test_ext_browserAction_onClicked.html | 100 + .../test_ext_browserAction_openPopup.html | 183 + ...t_browserAction_openPopup_incognito_window.html | 151 + .../test_ext_browserAction_openPopup_windowId.html | 162 + ...t_ext_browserAction_openPopup_without_pref.html | 58 + ...ext_browserSettings_overrideDocumentColors.html | 175 + .../mochitest/test_ext_browsingData_indexedDB.html | 159 + .../test_ext_browsingData_localStorage.html | 323 ++ .../test_ext_browsingData_pluginData.html | 69 + .../test_ext_browsingData_serviceWorkers.html | 141 + .../mochitest/test_ext_browsingData_settings.html | 65 + .../test_ext_canvas_resistFingerprinting.html | 65 + .../test/mochitest/test_ext_clipboard.html | 210 + .../test/mochitest/test_ext_clipboard_image.html | 262 ++ .../test_ext_contentscript_about_blank.html | 335 ++ .../test_ext_contentscript_activeTab.html | 703 ++++ .../mochitest/test_ext_contentscript_cache.html | 117 + .../mochitest/test_ext_contentscript_canvas.html | 134 + .../test_ext_contentscript_devtools_metadata.html | 77 + .../test_ext_contentscript_fission_frame.html | 109 + .../test_ext_contentscript_getFrameId.html | 189 + .../test_ext_contentscript_incognito.html | 101 + .../test_ext_contentscript_permission.html | 59 + .../test_ext_contentscript_securecontext.html | 163 + .../test/mochitest/test_ext_cookies.html | 367 ++ .../mochitest/test_ext_cookies_containers.html | 98 + .../test/mochitest/test_ext_cookies_expiry.html | 72 + .../mochitest/test_ext_cookies_first_party.html | 316 ++ .../test/mochitest/test_ext_cookies_incognito.html | 107 + .../test_ext_cookies_permissions_bad.html | 115 + .../test_ext_cookies_permissions_good.html | 89 + .../mochitest/test_ext_dnr_other_extensions.html | 113 + .../test/mochitest/test_ext_dnr_tabIds.html | 137 + .../test/mochitest/test_ext_dnr_upgradeScheme.html | 137 + .../mochitest/test_ext_downloads_download.html | 94 + ...test_ext_embeddedimg_iframe_frameAncestors.html | 94 + .../mochitest/test_ext_exclude_include_globs.html | 91 + .../test_ext_extension_iframe_messaging.html | 124 + .../mochitest/test_ext_external_messaging.html | 110 + .../test/mochitest/test_ext_generate.html | 48 + .../test/mochitest/test_ext_geolocation.html | 86 + .../test/mochitest/test_ext_identity.html | 390 ++ .../extensions/test/mochitest/test_ext_idle.html | 68 + .../test_ext_inIncognitoContext_window.html | 49 + .../test/mochitest/test_ext_listener_proxies.html | 62 + .../mochitest/test_ext_new_tab_processType.html | 168 + .../test/mochitest/test_ext_notifications.html | 340 ++ .../mochitest/test_ext_optional_permissions.html | 98 + .../mochitest/test_ext_pageAction_onClicked.html | 111 + .../test/mochitest/test_ext_protocolHandlers.html | 612 +++ .../test/mochitest/test_ext_redirect_jar.html | 92 + .../test_ext_request_urlClassification.html | 134 + .../test/mochitest/test_ext_runtime_connect.html | 83 + .../test/mochitest/test_ext_runtime_connect2.html | 102 + .../mochitest/test_ext_runtime_connect_iframe.html | 136 + .../mochitest/test_ext_runtime_connect_twoway.html | 126 + .../mochitest/test_ext_runtime_disconnect.html | 77 + .../test/mochitest/test_ext_script_filenames.html | 62 + .../test_ext_scripting_contentScripts.html | 1649 ++++++++ .../test_ext_scripting_executeScript.html | 1479 +++++++ ...test_ext_scripting_executeScript_activeTab.html | 144 + ..._scripting_executeScript_injectImmediately.html | 215 + .../mochitest/test_ext_scripting_insertCSS.html | 395 ++ .../mochitest/test_ext_scripting_permissions.html | 149 + .../mochitest/test_ext_scripting_removeCSS.html | 135 + .../test_ext_sendmessage_doublereply.html | 100 + .../mochitest/test_ext_sendmessage_frameId.html | 45 + .../test_ext_sendmessage_no_receiver.html | 115 + .../test/mochitest/test_ext_sendmessage_reply.html | 78 + .../mochitest/test_ext_sendmessage_reply2.html | 202 + .../test/mochitest/test_ext_storage_cleanup.html | 277 ++ .../test_ext_storage_manager_capabilities.html | 129 + .../mochitest/test_ext_storage_smoke_test.html | 108 + .../mochitest/test_ext_streamfilter_multiple.html | 91 + .../test_ext_streamfilter_processswitch.html | 76 + .../mochitest/test_ext_subframes_privileges.html | 340 ++ .../test/mochitest/test_ext_tabs_captureTab.html | 324 ++ .../test_ext_tabs_create_cookieStoreId.html | 210 + .../mochitest/test_ext_tabs_detectLanguage.html | 85 + .../test_ext_tabs_executeScript_good.html | 167 + .../test/mochitest/test_ext_tabs_permissions.html | 752 ++++ .../test/mochitest/test_ext_tabs_query_popup.html | 102 + .../test/mochitest/test_ext_tabs_sendMessage.html | 152 + .../extensions/test/mochitest/test_ext_test.html | 341 ++ .../test/mochitest/test_ext_unlimitedStorage.html | 139 + .../test_ext_web_accessible_incognito.html | 170 + .../test_ext_web_accessible_resources.html | 567 +++ .../test/mochitest/test_ext_webnavigation.html | 610 +++ .../mochitest/test_ext_webnavigation_filters.html | 313 ++ .../test_ext_webnavigation_incognito.html | 105 + .../test_ext_webrequest_and_proxy_filter.html | 131 + .../test/mochitest/test_ext_webrequest_auth.html | 181 + .../test_ext_webrequest_background_events.html | 120 + .../test/mochitest/test_ext_webrequest_basic.html | 445 +++ .../test/mochitest/test_ext_webrequest_errors.html | 59 + .../test/mochitest/test_ext_webrequest_filter.html | 226 ++ .../mochitest/test_ext_webrequest_frameId.html | 213 + .../test_ext_webrequest_getSecurityInfo.html | 98 + .../test/mochitest/test_ext_webrequest_hsts.html | 252 ++ .../test_ext_webrequest_redirect_bypass_cors.html | 75 + .../test_ext_webrequest_redirect_data_uri.html | 83 + .../mochitest/test_ext_webrequest_upgrade.html | 139 + .../test/mochitest/test_ext_webrequest_upload.html | 265 ++ .../test/mochitest/test_ext_webrequest_worker.html | 192 + .../mochitest/test_ext_window_postMessage.html | 104 + .../test/mochitest/test_startup_canary.html | 76 + .../mochitest/test_verify_non_remote_mode.html | 32 + .../test/mochitest/test_verify_remote_mode.html | 22 + .../test/mochitest/test_verify_sw_mode.html | 24 + .../test/mochitest/webrequest_chromeworker.js | 9 + .../test/mochitest/webrequest_test.sys.mjs | 16 + .../extensions/test/mochitest/webrequest_worker.js | 3 + .../extensions/test/xpcshell/.eslintrc.js | 13 + .../xpcshell/data/TestWorkerWatcherChild.sys.mjs | 62 + .../xpcshell/data/TestWorkerWatcherParent.sys.mjs | 20 + .../extensions/test/xpcshell/data/dummy_page.html | 7 + .../test/xpcshell/data/empty_file_download.txt | 0 .../test/xpcshell/data/file download.txt | 1 + .../test/xpcshell/data/file_WebRequest_page2.html | 25 + .../data/file_WebRequest_permission_original.html | 19 + .../data/file_WebRequest_permission_original.js | 2 + .../file_WebRequest_permission_redirected.html | 19 + .../data/file_WebRequest_permission_redirected.js | 2 + .../xpcshell/data/file_content_script_errors.html | 7 + .../extensions/test/xpcshell/data/file_csp.html | 14 + .../test/xpcshell/data/file_csp.html^headers^ | 1 + .../data/file_do_load_script_subresource.html | 9 + .../test/xpcshell/data/file_document_open.html | 21 + .../test/xpcshell/data/file_document_write.html | 36 + .../test/xpcshell/data/file_download.html | 12 + .../test/xpcshell/data/file_download.txt | 1 + .../extensions/test/xpcshell/data/file_iframe.html | 9 + .../test/xpcshell/data/file_image_bad.png | Bin 0 -> 5401 bytes .../test/xpcshell/data/file_image_good.png | Bin 0 -> 580 bytes .../test/xpcshell/data/file_image_redirect.png | Bin 0 -> 5401 bytes .../test/xpcshell/data/file_page_xhr.html | 34 + .../test/xpcshell/data/file_permission_xhr.html | 61 + .../xpcshell/data/file_privilege_escalation.html | 13 + .../extensions/test/xpcshell/data/file_sample.html | 12 + .../data/file_sample_registered_styles.html | 13 + .../extensions/test/xpcshell/data/file_script.html | 14 + .../test/xpcshell/data/file_script_bad.js | 12 + .../test/xpcshell/data/file_script_good.js | 12 + .../test/xpcshell/data/file_script_redirect.js | 3 + .../test/xpcshell/data/file_script_xhr.js | 9 + .../test/xpcshell/data/file_shadowdom.html | 13 + .../test/xpcshell/data/file_style_bad.css | 3 + .../test/xpcshell/data/file_style_good.css | 3 + .../test/xpcshell/data/file_style_redirect.css | 3 + .../test/xpcshell/data/file_stylesheet_cache.css | 1 + .../test/xpcshell/data/file_stylesheet_cache.html | 3 + .../xpcshell/data/file_stylesheet_cache_2.html | 19 + .../test/xpcshell/data/file_toplevel.html | 12 + .../test/xpcshell/data/file_with_iframe.html | 11 + .../xpcshell/data/file_with_xorigin_frame.html | 10 + .../extensions/test/xpcshell/data/lorem.html.gz | Bin 0 -> 392 bytes .../extensions/test/xpcshell/data/pixel_green.gif | Bin 0 -> 35 bytes .../extensions/test/xpcshell/data/pixel_red.gif | Bin 0 -> 35 bytes .../components/extensions/test/xpcshell/head.js | 530 +++ .../extensions/test/xpcshell/head_dnr.js | 203 + .../extensions/test/xpcshell/head_legacy_ep.js | 13 + .../test/xpcshell/head_native_messaging.js | 152 + .../extensions/test/xpcshell/head_remote.js | 7 + .../extensions/test/xpcshell/head_schemas.js | 129 + .../test/xpcshell/head_service_worker.js | 158 + .../extensions/test/xpcshell/head_storage.js | 1400 +++++++ .../extensions/test/xpcshell/head_sync.js | 66 + .../extensions/test/xpcshell/head_telemetry.js | 594 +++ .../extensions/test/xpcshell/native_messaging.toml | 19 + .../test/xpcshell/test_ExtensionShortcutKeyMap.js | 141 + .../test_ExtensionStorageSync_migration_kinto.js | 86 + .../extensions/test/xpcshell/test_MatchPattern.js | 685 ++++ .../test/xpcshell/test_QuarantinedDomains.js | 392 ++ .../xpcshell/test_QuarantinedDomains_telemetry.js | 99 + .../test/xpcshell/test_StorageSyncService.js | 274 ++ .../xpcshell/test_WebExtensionContentScript.js | 323 ++ .../test/xpcshell/test_WebExtensionPolicy.js | 620 +++ ...e_backgroundServiceWorker_enabled_pref_false.js | 47 + ...ge_backgroundServiceWorker_enabled_pref_true.js | 85 + .../test/xpcshell/test_change_remote_mode.js | 20 + .../test/xpcshell/test_csp_custom_policies.js | 303 ++ .../extensions/test/xpcshell/test_csp_validator.js | 322 ++ .../test/xpcshell/test_ext_MessageManagerProxy.js | 77 + .../test/xpcshell/test_ext_activityLog.js | 78 + .../test_ext_adoption_with_private_field_xrays.js | 160 + .../test/xpcshell/test_ext_adoption_with_xrays.js | 129 + .../extensions/test/xpcshell/test_ext_alarms.js | 346 ++ .../test/xpcshell/test_ext_alarms_does_not_fire.js | 34 + .../test/xpcshell/test_ext_alarms_periodic.js | 50 + .../test/xpcshell/test_ext_alarms_replaces.js | 56 + ...est_ext_api_events_listener_calls_exceptions.js | 369 ++ .../test/xpcshell/test_ext_api_permissions.js | 75 + .../test_ext_asyncAPICall_isHandlingUserInput.js | 149 + .../xpcshell/test_ext_background_api_injection.js | 41 + .../xpcshell/test_ext_background_early_shutdown.js | 905 +++++ .../test_ext_background_generated_load_events.js | 23 + .../test_ext_background_generated_reload.js | 24 + .../xpcshell/test_ext_background_global_history.js | 24 + .../test/xpcshell/test_ext_background_iframe.js | 349 ++ .../test_ext_background_private_browsing.js | 44 + .../test_ext_background_runtime_connect_params.js | 88 + ...est_ext_background_script_and_service_worker.js | 81 + .../xpcshell/test_ext_background_service_worker.js | 321 ++ .../xpcshell/test_ext_background_sub_windows.js | 46 + .../test/xpcshell/test_ext_background_teardown.js | 98 + .../test/xpcshell/test_ext_background_telemetry.js | 119 + .../xpcshell/test_ext_background_type_module.js | 133 + .../test_ext_background_window_properties.js | 41 + .../test/xpcshell/test_ext_brokenlinks.js | 54 + .../test/xpcshell/test_ext_browserSettings.js | 528 +++ .../xpcshell/test_ext_browserSettings_homepage.js | 36 + .../xpcshell/test_ext_browser_style_deprecation.js | 330 ++ .../test/xpcshell/test_ext_browsingData.js | 48 + .../test_ext_browsingData_cookies_cache.js | 456 +++ .../test_ext_browsingData_cookies_cookieStoreId.js | 192 + .../extensions/test/xpcshell/test_ext_cache_api.js | 296 ++ .../test/xpcshell/test_ext_captivePortal.js | 202 + .../test/xpcshell/test_ext_captivePortal_url.js | 53 + .../xpcshell/test_ext_clear_cached_resources.js | 418 ++ .../xpcshell/test_ext_contentScripts_register.js | 808 ++++ .../xpcshell/test_ext_content_security_policy.js | 362 ++ .../test/xpcshell/test_ext_contentscript.js | 270 ++ .../test_ext_contentscript_about_blank_start.js | 78 + .../test_ext_contentscript_api_injection.js | 65 + .../test_ext_contentscript_async_loading.js | 79 + .../test_ext_contentscript_canvas_tainting.js | 128 + .../xpcshell/test_ext_contentscript_context.js | 359 ++ .../test_ext_contentscript_context_isolation.js | 168 + .../test_ext_contentscript_create_iframe.js | 177 + .../test/xpcshell/test_ext_contentscript_csp.js | 433 +++ .../test/xpcshell/test_ext_contentscript_css.js | 48 + .../test_ext_contentscript_dynamic_registration.js | 205 + .../test/xpcshell/test_ext_contentscript_errors.js | 150 + .../test_ext_contentscript_exporthelpers.js | 98 + .../xpcshell/test_ext_contentscript_importmap.js | 123 + .../test_ext_contentscript_in_background.js | 48 + .../xpcshell/test_ext_contentscript_json_api.js | 102 + .../test_ext_contentscript_module_import.js | 277 ++ .../test_ext_contentscript_perf_observers.js | 71 + .../test_ext_contentscript_permissions_change.js | 104 + .../test_ext_contentscript_permissions_fetch.js | 87 + .../test_ext_contentscript_restrictSchemes.js | 149 + .../test_ext_contentscript_scriptCreated.js | 61 + .../xpcshell/test_ext_contentscript_teardown.js | 101 + .../test_ext_contentscript_triggeringPrincipal.js | 1385 +++++++ ...ntscript_unregister_during_loadContentScript.js | 91 + .../test_ext_contentscript_xml_prettyprint.js | 75 + .../test_ext_contentscript_xorigin_frame.js | 62 + .../test/xpcshell/test_ext_contentscript_xrays.js | 59 + .../extensions/test/xpcshell/test_ext_contexts.js | 201 + .../test/xpcshell/test_ext_contexts_gc.js | 277 ++ .../xpcshell/test_ext_contextual_identities.js | 607 +++ .../test_ext_contextual_identities_move.js | 99 + .../test/xpcshell/test_ext_cookieBehaviors.js | 590 +++ .../test/xpcshell/test_ext_cookies_errors.js | 168 + .../test/xpcshell/test_ext_cookies_firstParty.js | 348 ++ .../test/xpcshell/test_ext_cookies_onChanged.js | 142 + .../test/xpcshell/test_ext_cookies_partitionKey.js | 895 +++++ .../test/xpcshell/test_ext_cookies_samesite.js | 114 + .../test/xpcshell/test_ext_cors_mozextension.js | 220 ++ .../test/xpcshell/test_ext_csp_frame_ancestors.js | 221 ++ .../test/xpcshell/test_ext_csp_upgrade_requests.js | 74 + .../test/xpcshell/test_ext_debugging_utils.js | 318 ++ .../test/xpcshell/test_ext_dnr_allowAllRequests.js | 1231 ++++++ .../extensions/test/xpcshell/test_ext_dnr_api.js | 383 ++ .../test/xpcshell/test_ext_dnr_download.js | 193 + .../test/xpcshell/test_ext_dnr_dynamic_rules.js | 1245 ++++++ .../test/xpcshell/test_ext_dnr_modifyHeaders.js | 1072 +++++ .../test/xpcshell/test_ext_dnr_private_browsing.js | 130 + .../xpcshell/test_ext_dnr_redirect_transform.js | 723 ++++ .../test/xpcshell/test_ext_dnr_regexFilter.js | 590 +++ .../xpcshell/test_ext_dnr_regexFilter_limits.js | 549 +++ .../test/xpcshell/test_ext_dnr_session_rules.js | 1111 ++++++ .../test/xpcshell/test_ext_dnr_startup_cache.js | 651 ++++ .../test/xpcshell/test_ext_dnr_static_rules.js | 1850 +++++++++ .../xpcshell/test_ext_dnr_system_restrictions.js | 283 ++ .../test/xpcshell/test_ext_dnr_tabIds.js | 249 ++ .../test/xpcshell/test_ext_dnr_testMatchOutcome.js | 1499 +++++++ .../test/xpcshell/test_ext_dnr_urlFilter.js | 1159 ++++++ .../test/xpcshell/test_ext_dnr_webrequest.js | 296 ++ .../xpcshell/test_ext_dnr_without_webrequest.js | 877 +++++ .../extensions/test/xpcshell/test_ext_dns.js | 177 + .../extensions/test/xpcshell/test_ext_downloads.js | 38 + .../xpcshell/test_ext_downloads_cookieStoreId.js | 469 +++ .../test/xpcshell/test_ext_downloads_cookies.js | 219 ++ .../test/xpcshell/test_ext_downloads_download.js | 685 ++++ .../test/xpcshell/test_ext_downloads_eventpage.js | 162 + .../test/xpcshell/test_ext_downloads_misc.js | 1173 ++++++ .../xpcshell/test_ext_downloads_partitionKey.js | 199 + .../test/xpcshell/test_ext_downloads_private.js | 306 ++ .../test/xpcshell/test_ext_downloads_search.js | 682 ++++ .../test/xpcshell/test_ext_downloads_urlencoded.js | 257 ++ .../test/xpcshell/test_ext_error_location.js | 48 + .../test/xpcshell/test_ext_eventpage_idle.js | 774 ++++ .../test/xpcshell/test_ext_eventpage_messaging.js | 225 ++ .../test_ext_eventpage_messaging_wakeup.js | 329 ++ .../test/xpcshell/test_ext_eventpage_settings.js | 161 + .../test/xpcshell/test_ext_eventpage_warning.js | 99 + .../test/xpcshell/test_ext_experiments.js | 451 +++ .../extensions/test/xpcshell/test_ext_extension.js | 74 + .../test_ext_extensionPreferencesManager.js | 873 +++++ .../xpcshell/test_ext_extensionSettingsStore.js | 1085 ++++++ .../test_ext_extension_content_telemetry.js | 220 ++ .../xpcshell/test_ext_extension_page_navigated.js | 341 ++ .../xpcshell/test_ext_extension_startup_failure.js | 46 + .../test_ext_extension_startup_telemetry.js | 107 + .../test/xpcshell/test_ext_file_access.js | 193 + .../xpcshell/test_ext_geckoProfiler_control.js | 205 + .../test/xpcshell/test_ext_geckoProfiler_schema.js | 69 + .../extensions/test/xpcshell/test_ext_geturl.js | 64 + .../extensions/test/xpcshell/test_ext_i18n.js | 571 +++ .../extensions/test/xpcshell/test_ext_i18n_css.js | 194 + .../extensions/test/xpcshell/test_ext_idle.js | 361 ++ .../extensions/test/xpcshell/test_ext_incognito.js | 127 + .../test/xpcshell/test_ext_indexedDB_principal.js | 147 + .../extensions/test/xpcshell/test_ext_ipcBlob.js | 150 + .../test/xpcshell/test_ext_json_parser.js | 108 + .../extensions/test/xpcshell/test_ext_l10n.js | 165 + .../test/xpcshell/test_ext_localStorage.js | 50 + .../test/xpcshell/test_ext_management.js | 339 ++ .../xpcshell/test_ext_management_uninstall_self.js | 146 + .../extensions/test/xpcshell/test_ext_manifest.js | 488 +++ .../test_ext_manifest_content_security_policy.js | 114 + .../test/xpcshell/test_ext_manifest_incognito.js | 45 + .../test_ext_manifest_minimum_chrome_version.js | 12 + .../test_ext_manifest_minimum_opera_version.js | 12 + .../test/xpcshell/test_ext_manifest_themes.js | 35 + .../test/xpcshell/test_ext_messaging_startup.js | 277 ++ .../test/xpcshell/test_ext_native_messaging.js | 1111 ++++++ .../xpcshell/test_ext_native_messaging_perf.js | 131 + .../test_ext_native_messaging_unresponsive.js | 85 + .../test/xpcshell/test_ext_networkStatus.js | 209 + .../xpcshell/test_ext_notifications_incognito.js | 105 + .../xpcshell/test_ext_notifications_unsupported.js | 41 + .../xpcshell/test_ext_onmessage_removelistener.js | 30 + .../test/xpcshell/test_ext_permission_warnings.js | 870 +++++ .../test/xpcshell/test_ext_permission_xhr.js | 240 ++ .../test/xpcshell/test_ext_permissions.js | 1034 +++++ .../test/xpcshell/test_ext_permissions_api.js | 464 +++ .../test/xpcshell/test_ext_permissions_migrate.js | 268 ++ .../xpcshell/test_ext_permissions_uninstall.js | 157 + .../test/xpcshell/test_ext_persistent_events.js | 1718 ++++++++ .../extensions/test/xpcshell/test_ext_privacy.js | 979 +++++ .../test/xpcshell/test_ext_privacy_disable.js | 180 + .../test_ext_privacy_nonPersistentCookies.js | 54 + .../test/xpcshell/test_ext_privacy_update.js | 163 + .../test_ext_proxy_authorization_via_proxyinfo.js | 116 + .../test/xpcshell/test_ext_proxy_config.js | 620 +++ .../xpcshell/test_ext_proxy_containerIsolation.js | 59 + .../test/xpcshell/test_ext_proxy_onauthrequired.js | 302 ++ .../test/xpcshell/test_ext_proxy_settings.js | 102 + .../test/xpcshell/test_ext_proxy_socks.js | 660 ++++ .../test/xpcshell/test_ext_proxy_speculative.js | 53 + .../test/xpcshell/test_ext_proxy_startup.js | 247 ++ .../extensions/test/xpcshell/test_ext_redirects.js | 660 ++++ .../test_ext_runtime_connect_no_receiver.js | 26 + .../xpcshell/test_ext_runtime_getBackgroundPage.js | 172 + .../xpcshell/test_ext_runtime_getBrowserInfo.js | 26 + .../xpcshell/test_ext_runtime_getPlatformInfo.js | 36 + .../test/xpcshell/test_ext_runtime_id.js | 46 + .../xpcshell/test_ext_runtime_messaging_self.js | 84 + .../test_ext_runtime_onInstalled_and_onStartup.js | 599 +++ .../test/xpcshell/test_ext_runtime_ports.js | 69 + .../test/xpcshell/test_ext_runtime_ports_gc.js | 170 + .../test/xpcshell/test_ext_runtime_sendMessage.js | 462 +++ .../xpcshell/test_ext_runtime_sendMessage_args.js | 118 + .../test_ext_runtime_sendMessage_errors.js | 66 + .../test_ext_runtime_sendMessage_multiple.js | 67 + .../test_ext_runtime_sendMessage_no_receiver.js | 93 + .../test/xpcshell/test_ext_same_site_cookies.js | 131 + .../test/xpcshell/test_ext_same_site_redirects.js | 239 ++ .../test/xpcshell/test_ext_sandbox_var.js | 42 + .../test/xpcshell/test_ext_sandboxed_resource.js | 55 + .../extensions/test/xpcshell/test_ext_schema.js | 80 + .../extensions/test/xpcshell/test_ext_schemas.js | 2118 ++++++++++ .../xpcshell/test_ext_schemas_allowed_contexts.js | 160 + .../test/xpcshell/test_ext_schemas_async.js | 352 ++ .../test/xpcshell/test_ext_schemas_interactive.js | 173 + .../test_ext_schemas_manifest_permissions.js | 171 + .../test/xpcshell/test_ext_schemas_privileged.js | 161 + .../test/xpcshell/test_ext_schemas_revoke.js | 507 +++ .../test/xpcshell/test_ext_schemas_roots.js | 242 ++ .../test/xpcshell/test_ext_schemas_versioned.js | 714 ++++ .../test/xpcshell/test_ext_script_filenames.js | 366 ++ .../xpcshell/test_ext_scripting_contentScripts.js | 540 +++ .../test_ext_scripting_contentScripts_css.js | 331 ++ .../test_ext_scripting_contentScripts_file.js | 77 + .../test/xpcshell/test_ext_scripting_mv2.js | 23 + .../test_ext_scripting_persistAcrossSessions.js | 760 ++++ .../xpcshell/test_ext_scripting_startupCache.js | 167 + .../test_ext_scripting_updateContentScripts.js | 114 + .../extensions/test/xpcshell/test_ext_secfetch.js | 352 ++ .../extensions/test/xpcshell/test_ext_shadowdom.js | 59 + .../test/xpcshell/test_ext_shared_array_buffer.js | 104 + .../test/xpcshell/test_ext_shared_workers.js | 40 + .../test/xpcshell/test_ext_shutdown_cleanup.js | 43 + .../extensions/test/xpcshell/test_ext_simple.js | 208 + .../test/xpcshell/test_ext_startupData.js | 55 + .../test/xpcshell/test_ext_startup_cache.js | 178 + .../xpcshell/test_ext_startup_cache_telemetry.js | 173 + .../test/xpcshell/test_ext_startup_perf.js | 70 + .../xpcshell/test_ext_startup_request_handler.js | 69 + .../xpcshell/test_ext_storage_content_local.js | 39 + .../test/xpcshell/test_ext_storage_content_sync.js | 31 + .../test_ext_storage_content_sync_kinto.js | 31 + .../test_ext_storage_idb_data_migration.js | 806 ++++ .../test/xpcshell/test_ext_storage_local.js | 83 + .../test/xpcshell/test_ext_storage_managed.js | 212 + .../xpcshell/test_ext_storage_managed_policy.js | 44 + .../test_ext_storage_quota_exceeded_errors.js | 80 + .../test/xpcshell/test_ext_storage_sanitizer.js | 107 + .../test/xpcshell/test_ext_storage_session.js | 165 + .../test/xpcshell/test_ext_storage_sync.js | 35 + .../test/xpcshell/test_ext_storage_sync_kinto.js | 2320 +++++++++++ .../xpcshell/test_ext_storage_sync_kinto_crypto.js | 122 + .../test/xpcshell/test_ext_storage_tab.js | 245 ++ .../test/xpcshell/test_ext_storage_telemetry.js | 458 +++ .../test/xpcshell/test_ext_tab_teardown.js | 97 + .../extensions/test/xpcshell/test_ext_telemetry.js | 917 +++++ .../extensions/test/xpcshell/test_ext_test_mock.js | 55 + .../test/xpcshell/test_ext_test_wrapper.js | 60 + .../test/xpcshell/test_ext_theme_experiments.js | 109 + .../test/xpcshell/test_ext_trustworthy_origin.js | 20 + .../test/xpcshell/test_ext_unknown_permissions.js | 60 + .../test/xpcshell/test_ext_unlimitedStorage.js | 211 + .../test/xpcshell/test_ext_unload_frame.js | 230 ++ .../test/xpcshell/test_ext_userScripts.js | 709 ++++ .../test/xpcshell/test_ext_userScripts_exports.js | 1108 ++++++ .../test/xpcshell/test_ext_userScripts_register.js | 142 + .../extensions/test/xpcshell/test_ext_wasm.js | 135 + .../test/xpcshell/test_ext_webRequest_auth.js | 425 ++ .../test/xpcshell/test_ext_webRequest_cached.js | 311 ++ .../test_ext_webRequest_cancelWithReason.js | 68 + .../test_ext_webRequest_containerIsolation.js | 59 + .../test/xpcshell/test_ext_webRequest_download.js | 44 + .../test_ext_webRequest_eventPage_StreamFilter.js | 350 ++ .../test_ext_webRequest_filterResponseData.js | 607 +++ .../xpcshell/test_ext_webRequest_filterTypes.js | 87 + .../xpcshell/test_ext_webRequest_filter_urls.js | 35 + .../test_ext_webRequest_from_extension_page.js | 57 + .../test/xpcshell/test_ext_webRequest_host.js | 99 + .../test/xpcshell/test_ext_webRequest_incognito.js | 88 + .../test/xpcshell/test_ext_webRequest_mergecsp.js | 545 +++ .../xpcshell/test_ext_webRequest_permission.js | 153 + .../test_ext_webRequest_redirectProperty.js | 64 + .../test_ext_webRequest_redirect_StreamFilter.js | 129 + .../test_ext_webRequest_redirect_mozextension.js | 47 + .../xpcshell/test_ext_webRequest_requestSize.js | 57 + .../xpcshell/test_ext_webRequest_responseBody.js | 764 ++++ .../test_ext_webRequest_restrictedHeaders.js | 252 ++ .../xpcshell/test_ext_webRequest_set_cookie.js | 308 ++ .../test/xpcshell/test_ext_webRequest_startup.js | 751 ++++ .../test_ext_webRequest_startup_StreamFilter.js | 76 + .../xpcshell/test_ext_webRequest_style_cache.js | 49 + .../test/xpcshell/test_ext_webRequest_suspend.js | 289 ++ .../test_ext_webRequest_urlclassification.js | 45 + .../xpcshell/test_ext_webRequest_userContextId.js | 41 + .../xpcshell/test_ext_webRequest_viewsource.js | 95 + .../test_ext_webRequest_viewsource_StreamFilter.js | 144 + .../test/xpcshell/test_ext_webRequest_webSocket.js | 55 + .../extensions/test/xpcshell/test_ext_webSocket.js | 162 + .../xpcshell/test_ext_web_accessible_resources.js | 148 + .../test_ext_web_accessible_resources_matches.js | 546 +++ .../test/xpcshell/test_ext_xhr_capabilities.js | 72 + .../extensions/test/xpcshell/test_ext_xhr_cors.js | 223 ++ ...t_extension_permissions_migrate_kvstore_path.js | 234 ++ .../test_extension_permissions_migration.js | 112 + .../test/xpcshell/test_extension_process_alive.js | 312 ++ .../test/xpcshell/test_load_all_api_modules.js | 171 + .../test/xpcshell/test_locale_converter.js | 146 + .../extensions/test/xpcshell/test_locale_data.js | 221 ++ .../test/xpcshell/test_native_manifests.js | 543 +++ .../test/xpcshell/test_process_crash_telemetry.js | 126 + .../test/xpcshell/test_proxy_failover.js | 325 ++ .../test/xpcshell/test_proxy_incognito.js | 95 + .../test/xpcshell/test_proxy_info_results.js | 462 +++ .../test/xpcshell/test_proxy_listener.js | 298 ++ .../test/xpcshell/test_proxy_userContextId.js | 43 + .../xpcshell/test_resistfingerprinting_exempt.js | 40 + .../test/xpcshell/test_site_permissions.js | 385 ++ .../test/xpcshell/test_webRequest_ancestors.js | 81 + .../test/xpcshell/test_webRequest_cookies.js | 102 + .../test/xpcshell/test_webRequest_filtering.js | 182 + .../test/xpcshell/webidl-api/.eslintrc.js | 9 + .../test/xpcshell/webidl-api/head_webidl_api.js | 306 ++ .../xpcshell/webidl-api/test_ext_webidl_api.js | 486 +++ .../test_ext_webidl_api_event_callback.js | 575 +++ .../test_ext_webidl_api_request_handler.js | 443 +++ .../test_ext_webidl_api_schema_errors.js | 202 + .../test_ext_webidl_api_schema_formatters.js | 99 + .../webidl-api/test_ext_webidl_runtime_port.js | 220 ++ .../test/xpcshell/webidl-api/xpcshell.toml | 34 + .../test/xpcshell/xpcshell-common-e10s.toml | 26 + .../extensions/test/xpcshell/xpcshell-common.toml | 682 ++++ .../extensions/test/xpcshell/xpcshell-content.toml | 88 + .../extensions/test/xpcshell/xpcshell-e10s.toml | 32 + .../test/xpcshell/xpcshell-legacy-ep.toml | 28 + .../extensions/test/xpcshell/xpcshell-remote.toml | 52 + .../test/xpcshell/xpcshell-serviceworker.toml | 51 + .../extensions/test/xpcshell/xpcshell.toml | 137 + toolkit/components/extensions/tsconfig.json | 56 + toolkit/components/extensions/types/README.md | 86 + .../types/XPCShellContentUtils.sys.d.mts | 87 + toolkit/components/extensions/types/extensions.ts | 80 + toolkit/components/extensions/types/gecko.ts | 163 + toolkit/components/extensions/types/globals.ts | 33 + .../extensions/webidl-api/ExtensionAPI.cpp.in | 56 + .../extensions/webidl-api/ExtensionAPI.h.in | 71 + .../extensions/webidl-api/ExtensionAPI.webidl.in | 33 + .../webidl-api/ExtensionAPIAddRemoveListener.h | 36 + .../extensions/webidl-api/ExtensionAPIBase.cpp | 364 ++ .../extensions/webidl-api/ExtensionAPIBase.h | 198 + .../webidl-api/ExtensionAPICallAsyncFunction.h | 29 + .../webidl-api/ExtensionAPICallFunctionNoReturn.h | 29 + .../webidl-api/ExtensionAPICallSyncFunction.h | 29 + .../webidl-api/ExtensionAPIGetProperty.h | 29 + .../extensions/webidl-api/ExtensionAPIRequest.cpp | 242 ++ .../extensions/webidl-api/ExtensionAPIRequest.h | 121 + .../webidl-api/ExtensionAPIRequestForwarder.cpp | 705 ++++ .../webidl-api/ExtensionAPIRequestForwarder.h | 258 ++ .../extensions/webidl-api/ExtensionAlarms.cpp | 49 + .../extensions/webidl-api/ExtensionAlarms.h | 70 + .../extensions/webidl-api/ExtensionBrowser.cpp | 345 ++ .../extensions/webidl-api/ExtensionBrowser.h | 154 + .../webidl-api/ExtensionBrowserSettings.cpp | 106 + .../webidl-api/ExtensionBrowserSettings.h | 107 + .../ExtensionBrowserSettingsColorManagement.cpp | 58 + .../ExtensionBrowserSettingsColorManagement.h | 77 + .../extensions/webidl-api/ExtensionDns.cpp | 40 + .../extensions/webidl-api/ExtensionDns.h | 63 + .../webidl-api/ExtensionEventListener.cpp | 677 ++++ .../extensions/webidl-api/ExtensionEventListener.h | 234 ++ .../webidl-api/ExtensionEventManager.cpp | 167 + .../extensions/webidl-api/ExtensionEventManager.h | 99 + .../extensions/webidl-api/ExtensionMockAPI.cpp | 59 + .../extensions/webidl-api/ExtensionMockAPI.h | 78 + .../extensions/webidl-api/ExtensionPort.cpp | 109 + .../extensions/webidl-api/ExtensionPort.h | 96 + .../extensions/webidl-api/ExtensionProxy.cpp | 51 + .../extensions/webidl-api/ExtensionProxy.h | 72 + .../extensions/webidl-api/ExtensionRuntime.cpp | 67 + .../extensions/webidl-api/ExtensionRuntime.h | 85 + .../extensions/webidl-api/ExtensionScripting.cpp | 43 + .../extensions/webidl-api/ExtensionScripting.h | 67 + .../extensions/webidl-api/ExtensionSetting.cpp | 48 + .../extensions/webidl-api/ExtensionSetting.h | 69 + .../extensions/webidl-api/ExtensionTest.cpp | 527 +++ .../extensions/webidl-api/ExtensionTest.h | 102 + .../extensions/webidl-api/ExtensionWebIDL.conf | 101 + .../webidl-api/GenerateWebIDLBindings.py | 1612 ++++++++ .../extensions/webidl-api/InspectJSONSchema.py | 152 + toolkit/components/extensions/webidl-api/moz.build | 78 + .../extensions/webidl-api/test/README.md | 60 + .../extensions/webidl-api/test/conftest.py | 39 + .../extensions/webidl-api/test/helpers.py | 22 + .../extensions/webidl-api/test/python.toml | 10 + .../webidl-api/test/test_all_schemas_smoketest.py | 22 + .../webidl-api/test/test_json_schema_parsing.py | 215 + .../test/test_json_schema_platform_diffs.py | 153 + .../test/test_webidl_from_json_schema.py | 110 + .../extensions/webrequest/ChannelWrapper.cpp | 1281 ++++++ .../extensions/webrequest/ChannelWrapper.h | 357 ++ .../extensions/webrequest/PStreamFilter.ipdl | 39 + .../extensions/webrequest/SecurityInfo.sys.mjs | 359 ++ .../extensions/webrequest/StreamFilter.cpp | 267 ++ .../extensions/webrequest/StreamFilter.h | 96 + .../extensions/webrequest/StreamFilterBase.h | 38 + .../extensions/webrequest/StreamFilterChild.cpp | 516 +++ .../extensions/webrequest/StreamFilterChild.h | 135 + .../extensions/webrequest/StreamFilterEvents.cpp | 53 + .../extensions/webrequest/StreamFilterEvents.h | 64 + .../extensions/webrequest/StreamFilterParent.cpp | 850 ++++ .../extensions/webrequest/StreamFilterParent.h | 198 + .../extensions/webrequest/WebNavigationContent.cpp | 325 ++ .../extensions/webrequest/WebNavigationContent.h | 57 + .../extensions/webrequest/WebRequest.sys.mjs | 1337 +++++++ .../extensions/webrequest/WebRequestService.cpp | 55 + .../extensions/webrequest/WebRequestService.h | 79 + .../extensions/webrequest/WebRequestUpload.sys.mjs | 560 +++ .../extensions/webrequest/components.conf | 16 + toolkit/components/extensions/webrequest/moz.build | 60 + 938 files changed, 219770 insertions(+) create mode 100644 toolkit/components/extensions/.eslintrc.js create mode 100644 toolkit/components/extensions/ConduitsChild.sys.mjs create mode 100644 toolkit/components/extensions/ConduitsParent.sys.mjs create mode 100644 toolkit/components/extensions/DocumentObserver.h create mode 100644 toolkit/components/extensions/Extension.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionActions.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionActivityLog.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionChild.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionChildDevToolsUtils.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionCommon.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionContent.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionDNR.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionDNRLimits.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionDNRStore.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionPageChild.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionParent.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionPermissionMessages.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionPermissions.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionPolicyService.cpp create mode 100644 toolkit/components/extensions/ExtensionPolicyService.h create mode 100644 toolkit/components/extensions/ExtensionPreferencesManager.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionProcessScript.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionScriptingStore.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionSettingsStore.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionShortcuts.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionStorage.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionStorageIDB.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionStorageSync.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionStorageSyncKinto.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionTelemetry.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionTestCommon.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionUtils.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionWorkerChild.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionXPCShellUtils.sys.mjs create mode 100644 toolkit/components/extensions/ExtensionsChild.cpp create mode 100644 toolkit/components/extensions/ExtensionsChild.h create mode 100644 toolkit/components/extensions/ExtensionsParent.cpp create mode 100644 toolkit/components/extensions/ExtensionsParent.h create mode 100644 toolkit/components/extensions/FindContent.sys.mjs create mode 100644 toolkit/components/extensions/MatchGlob.h create mode 100644 toolkit/components/extensions/MatchPattern.cpp create mode 100644 toolkit/components/extensions/MatchPattern.h create mode 100644 toolkit/components/extensions/MatchURLFilters.sys.mjs create mode 100644 toolkit/components/extensions/MessageChannel.sys.mjs create mode 100644 toolkit/components/extensions/MessageManagerProxy.sys.mjs create mode 100644 toolkit/components/extensions/NativeManifests.sys.mjs create mode 100644 toolkit/components/extensions/NativeMessaging.sys.mjs create mode 100644 toolkit/components/extensions/PExtensions.ipdl create mode 100644 toolkit/components/extensions/ProxyChannelFilter.sys.mjs create mode 100644 toolkit/components/extensions/Schemas.sys.mjs create mode 100644 toolkit/components/extensions/WebExtensionContentScript.h create mode 100644 toolkit/components/extensions/WebExtensionPolicy.cpp create mode 100644 toolkit/components/extensions/WebExtensionPolicy.h create mode 100644 toolkit/components/extensions/WebNavigation.sys.mjs create mode 100644 toolkit/components/extensions/WebNavigationFrames.sys.mjs create mode 100644 toolkit/components/extensions/child/.eslintrc.js create mode 100644 toolkit/components/extensions/child/ext-backgroundPage.js create mode 100644 toolkit/components/extensions/child/ext-contentScripts.js create mode 100644 toolkit/components/extensions/child/ext-declarativeNetRequest.js create mode 100644 toolkit/components/extensions/child/ext-extension.js create mode 100644 toolkit/components/extensions/child/ext-identity.js create mode 100644 toolkit/components/extensions/child/ext-runtime.js create mode 100644 toolkit/components/extensions/child/ext-scripting.js create mode 100644 toolkit/components/extensions/child/ext-storage.js create mode 100644 toolkit/components/extensions/child/ext-test.js create mode 100644 toolkit/components/extensions/child/ext-toolkit.js create mode 100644 toolkit/components/extensions/child/ext-userScripts-content.js create mode 100644 toolkit/components/extensions/child/ext-userScripts.js create mode 100644 toolkit/components/extensions/child/ext-webRequest.js create mode 100644 toolkit/components/extensions/components.conf create mode 100644 toolkit/components/extensions/docs/background.rst create mode 100644 toolkit/components/extensions/docs/basics.rst create mode 100644 toolkit/components/extensions/docs/events.rst create mode 100644 toolkit/components/extensions/docs/functions.rst create mode 100644 toolkit/components/extensions/docs/generate_webidl_from_jsonschema.rst create mode 100644 toolkit/components/extensions/docs/generate_webidl_from_jsonschema_dataflow.drawio.svg create mode 100644 toolkit/components/extensions/docs/incognito.rst create mode 100644 toolkit/components/extensions/docs/index.rst create mode 100644 toolkit/components/extensions/docs/lifecycle.rst create mode 100644 toolkit/components/extensions/docs/manifest.rst create mode 100644 toolkit/components/extensions/docs/other.rst create mode 100644 toolkit/components/extensions/docs/reference.rst create mode 100644 toolkit/components/extensions/docs/schema.rst create mode 100644 toolkit/components/extensions/docs/webext-storage.rst create mode 100644 toolkit/components/extensions/docs/webidl_bindings.rst create mode 100644 toolkit/components/extensions/docs/webidl_bindings_backgroundWorker_apiRequestHandling.drawio.svg create mode 100644 toolkit/components/extensions/docs/wiring_up_new_webidl_bindings.rst create mode 100644 toolkit/components/extensions/dummy.xhtml create mode 100644 toolkit/components/extensions/ext-browser-content.js create mode 100644 toolkit/components/extensions/ext-toolkit.json create mode 100644 toolkit/components/extensions/extIWebNavigation.idl create mode 100644 toolkit/components/extensions/extensionProcessScriptLoader.js create mode 100644 toolkit/components/extensions/extensions-toolkit.manifest create mode 100644 toolkit/components/extensions/jar.mn create mode 100644 toolkit/components/extensions/metrics.yaml create mode 100644 toolkit/components/extensions/moz.build create mode 100644 toolkit/components/extensions/mozIExtensionAPIRequestHandling.idl create mode 100644 toolkit/components/extensions/mozIExtensionProcessScript.idl create mode 100644 toolkit/components/extensions/parent/.eslintrc.js create mode 100644 toolkit/components/extensions/parent/ext-activityLog.js create mode 100644 toolkit/components/extensions/parent/ext-alarms.js create mode 100644 toolkit/components/extensions/parent/ext-backgroundPage.js create mode 100644 toolkit/components/extensions/parent/ext-browserSettings.js create mode 100644 toolkit/components/extensions/parent/ext-browsingData.js create mode 100644 toolkit/components/extensions/parent/ext-captivePortal.js create mode 100644 toolkit/components/extensions/parent/ext-clipboard.js create mode 100644 toolkit/components/extensions/parent/ext-contentScripts.js create mode 100644 toolkit/components/extensions/parent/ext-contextualIdentities.js create mode 100644 toolkit/components/extensions/parent/ext-cookies.js create mode 100644 toolkit/components/extensions/parent/ext-declarativeNetRequest.js create mode 100644 toolkit/components/extensions/parent/ext-dns.js create mode 100644 toolkit/components/extensions/parent/ext-downloads.js create mode 100644 toolkit/components/extensions/parent/ext-extension.js create mode 100644 toolkit/components/extensions/parent/ext-geckoProfiler.js create mode 100644 toolkit/components/extensions/parent/ext-i18n.js create mode 100644 toolkit/components/extensions/parent/ext-identity.js create mode 100644 toolkit/components/extensions/parent/ext-idle.js create mode 100644 toolkit/components/extensions/parent/ext-management.js create mode 100644 toolkit/components/extensions/parent/ext-networkStatus.js create mode 100644 toolkit/components/extensions/parent/ext-notifications.js create mode 100644 toolkit/components/extensions/parent/ext-permissions.js create mode 100644 toolkit/components/extensions/parent/ext-privacy.js create mode 100644 toolkit/components/extensions/parent/ext-protocolHandlers.js create mode 100644 toolkit/components/extensions/parent/ext-proxy.js create mode 100644 toolkit/components/extensions/parent/ext-runtime.js create mode 100644 toolkit/components/extensions/parent/ext-scripting.js create mode 100644 toolkit/components/extensions/parent/ext-storage.js create mode 100644 toolkit/components/extensions/parent/ext-tabs-base.js create mode 100644 toolkit/components/extensions/parent/ext-telemetry.js create mode 100644 toolkit/components/extensions/parent/ext-theme.js create mode 100644 toolkit/components/extensions/parent/ext-toolkit.js create mode 100644 toolkit/components/extensions/parent/ext-userScripts.js create mode 100644 toolkit/components/extensions/parent/ext-webNavigation.js create mode 100644 toolkit/components/extensions/parent/ext-webRequest.js create mode 100644 toolkit/components/extensions/schemas/LICENSE-CHROMIUM create mode 100644 toolkit/components/extensions/schemas/README.md create mode 100644 toolkit/components/extensions/schemas/activity_log.json create mode 100644 toolkit/components/extensions/schemas/alarms.json create mode 100644 toolkit/components/extensions/schemas/browser_action.json create mode 100644 toolkit/components/extensions/schemas/browser_settings.json create mode 100644 toolkit/components/extensions/schemas/browsing_data.json create mode 100644 toolkit/components/extensions/schemas/captive_portal.json create mode 100644 toolkit/components/extensions/schemas/clipboard.json create mode 100644 toolkit/components/extensions/schemas/content_scripts.json create mode 100644 toolkit/components/extensions/schemas/contextual_identities.json create mode 100644 toolkit/components/extensions/schemas/cookies.json create mode 100644 toolkit/components/extensions/schemas/declarative_net_request.json create mode 100644 toolkit/components/extensions/schemas/dns.json create mode 100644 toolkit/components/extensions/schemas/downloads.json create mode 100644 toolkit/components/extensions/schemas/events.json create mode 100644 toolkit/components/extensions/schemas/experiments.json create mode 100644 toolkit/components/extensions/schemas/extension.json create mode 100644 toolkit/components/extensions/schemas/extension_protocol_handlers.json create mode 100644 toolkit/components/extensions/schemas/extension_types.json create mode 100644 toolkit/components/extensions/schemas/geckoProfiler.json create mode 100644 toolkit/components/extensions/schemas/i18n.json create mode 100644 toolkit/components/extensions/schemas/identity.json create mode 100644 toolkit/components/extensions/schemas/idle.json create mode 100644 toolkit/components/extensions/schemas/jar.mn create mode 100644 toolkit/components/extensions/schemas/management.json create mode 100644 toolkit/components/extensions/schemas/manifest.json create mode 100644 toolkit/components/extensions/schemas/moz.build create mode 100644 toolkit/components/extensions/schemas/native_manifest.json create mode 100644 toolkit/components/extensions/schemas/network_status.json create mode 100644 toolkit/components/extensions/schemas/notifications.json create mode 100644 toolkit/components/extensions/schemas/page_action.json create mode 100644 toolkit/components/extensions/schemas/permissions.json create mode 100644 toolkit/components/extensions/schemas/privacy.json create mode 100644 toolkit/components/extensions/schemas/proxy.json create mode 100644 toolkit/components/extensions/schemas/runtime.json create mode 100644 toolkit/components/extensions/schemas/scripting.json create mode 100644 toolkit/components/extensions/schemas/storage.json create mode 100644 toolkit/components/extensions/schemas/telemetry.json create mode 100644 toolkit/components/extensions/schemas/test.json create mode 100644 toolkit/components/extensions/schemas/theme.json create mode 100644 toolkit/components/extensions/schemas/types.json create mode 100644 toolkit/components/extensions/schemas/user_scripts.json create mode 100644 toolkit/components/extensions/schemas/user_scripts_content.json create mode 100644 toolkit/components/extensions/schemas/web_navigation.json create mode 100644 toolkit/components/extensions/schemas/web_request.json create mode 100644 toolkit/components/extensions/storage/ExtensionStorageComponents.h create mode 100644 toolkit/components/extensions/storage/ExtensionStorageComponents.sys.mjs create mode 100644 toolkit/components/extensions/storage/components.conf create mode 100644 toolkit/components/extensions/storage/moz.build create mode 100644 toolkit/components/extensions/storage/mozIExtensionStorageArea.idl create mode 100644 toolkit/components/extensions/storage/webext_storage_bridge/Cargo.toml create mode 100644 toolkit/components/extensions/storage/webext_storage_bridge/src/area.rs create mode 100644 toolkit/components/extensions/storage/webext_storage_bridge/src/error.rs create mode 100644 toolkit/components/extensions/storage/webext_storage_bridge/src/lib.rs create mode 100644 toolkit/components/extensions/storage/webext_storage_bridge/src/punt.rs create mode 100644 toolkit/components/extensions/storage/webext_storage_bridge/src/store.rs create mode 100644 toolkit/components/extensions/test/browser/.eslintrc.js create mode 100644 toolkit/components/extensions/test/browser/browser-serviceworker.toml create mode 100644 toolkit/components/extensions/test/browser/browser.toml create mode 100644 toolkit/components/extensions/test/browser/browser_ext_background_serviceworker.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_background_serviceworker_pref_disabled.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_downloads_filters.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_downloads_referrer.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_eventpage_disableResetIdleForTest.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_extension_page_tab_navigated.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_management_themes.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_process_crash_handling.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_test_mock.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_additional_backgrounds_alignment.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_alpha_accentcolor.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_arrowpanels.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_autocomplete_popup.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_chromeparity.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_getCurrent.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_onUpdated.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_updates.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_experiment.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_findbar.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_getCurrent_differentExt.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_highlight.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_incognito.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_lwtsupport.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_multiple_backgrounds.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors_perwindow.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_pbm.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_persistence.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_reset.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_sanitization.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_separators.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_sidebars.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_static_onUpdated.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_tab_line.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_tab_loading.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_tab_selected.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_tab_text.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_theme_transition.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields_focus.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_colors.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_icons.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_toolbars.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_themes_warnings.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_thumbnails_bg_extension.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_webNavigation_eventpage.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_webRequest_redirect_mozextension.js create mode 100644 toolkit/components/extensions/test/browser/browser_ext_windows_popup_title.js create mode 100644 toolkit/components/extensions/test/browser/data/test-download.txt create mode 100644 toolkit/components/extensions/test/browser/data/test_downloads_referrer.html create mode 100644 toolkit/components/extensions/test/browser/head.js create mode 100644 toolkit/components/extensions/test/browser/head_serviceworker.js create mode 100644 toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/manifest.json create mode 100644 toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/sw.js create mode 100644 toolkit/components/extensions/test/marionette/manifest-serviceworker.toml create mode 100644 toolkit/components/extensions/test/marionette/service_worker_testutils.py create mode 100644 toolkit/components/extensions/test/marionette/test_extension_serviceworkers_purged_on_pref_disabled.py create mode 100644 toolkit/components/extensions/test/marionette/test_temporary_extension_serviceworkers_not_persisted.py create mode 100644 toolkit/components/extensions/test/mochitest/.eslintrc.js create mode 100644 toolkit/components/extensions/test/mochitest/chrome.toml create mode 100644 toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js create mode 100644 toolkit/components/extensions/test/mochitest/chrome_head.js create mode 100644 toolkit/components/extensions/test/mochitest/file_WebNavigation_page1.html create mode 100644 toolkit/components/extensions/test/mochitest/file_WebNavigation_page2.html create mode 100644 toolkit/components/extensions/test/mochitest/file_WebNavigation_page3.html create mode 100644 toolkit/components/extensions/test/mochitest/file_WebRequest_page3.html create mode 100644 toolkit/components/extensions/test/mochitest/file_contains_iframe.html create mode 100644 toolkit/components/extensions/test/mochitest/file_contains_img.html create mode 100644 toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html create mode 100644 toolkit/components/extensions/test/mochitest/file_contentscript_activeTab2.html create mode 100644 toolkit/components/extensions/test/mochitest/file_contentscript_iframe.html create mode 100644 toolkit/components/extensions/test/mochitest/file_green.html create mode 100644 toolkit/components/extensions/test/mochitest/file_green_blue.html create mode 100644 toolkit/components/extensions/test/mochitest/file_image_bad.png create mode 100644 toolkit/components/extensions/test/mochitest/file_image_good.png create mode 100644 toolkit/components/extensions/test/mochitest/file_image_great.png create mode 100644 toolkit/components/extensions/test/mochitest/file_image_redirect.png create mode 100644 toolkit/components/extensions/test/mochitest/file_indexedDB.html create mode 100644 toolkit/components/extensions/test/mochitest/file_language_fr_en.html create mode 100644 toolkit/components/extensions/test/mochitest/file_language_ja.html create mode 100644 toolkit/components/extensions/test/mochitest/file_language_tlh.html create mode 100644 toolkit/components/extensions/test/mochitest/file_mixed.html create mode 100644 toolkit/components/extensions/test/mochitest/file_redirect_cors_bypass.html create mode 100644 toolkit/components/extensions/test/mochitest/file_redirect_data_uri.html create mode 100644 toolkit/components/extensions/test/mochitest/file_remote_frame.html create mode 100644 toolkit/components/extensions/test/mochitest/file_sample.html create mode 100644 toolkit/components/extensions/test/mochitest/file_sample.txt create mode 100644 toolkit/components/extensions/test/mochitest/file_sample.txt^headers^ create mode 100644 toolkit/components/extensions/test/mochitest/file_script_bad.js create mode 100644 toolkit/components/extensions/test/mochitest/file_script_good.js create mode 100644 toolkit/components/extensions/test/mochitest/file_script_redirect.js create mode 100644 toolkit/components/extensions/test/mochitest/file_script_xhr.js create mode 100644 toolkit/components/extensions/test/mochitest/file_serviceWorker.html create mode 100644 toolkit/components/extensions/test/mochitest/file_simple_iframe_worker.html create mode 100644 toolkit/components/extensions/test/mochitest/file_simple_sandboxed_frame.html create mode 100644 toolkit/components/extensions/test/mochitest/file_simple_sandboxed_subframe.html create mode 100644 toolkit/components/extensions/test/mochitest/file_simple_sharedworker.js create mode 100644 toolkit/components/extensions/test/mochitest/file_simple_webrequest_worker.html create mode 100644 toolkit/components/extensions/test/mochitest/file_simple_worker.js create mode 100644 toolkit/components/extensions/test/mochitest/file_simple_xhr.html create mode 100644 toolkit/components/extensions/test/mochitest/file_simple_xhr_frame.html create mode 100644 toolkit/components/extensions/test/mochitest/file_simple_xhr_frame2.html create mode 100644 toolkit/components/extensions/test/mochitest/file_slowed_document.sjs create mode 100644 toolkit/components/extensions/test/mochitest/file_streamfilter.txt create mode 100644 toolkit/components/extensions/test/mochitest/file_style_bad.css create mode 100644 toolkit/components/extensions/test/mochitest/file_style_good.css create mode 100644 toolkit/components/extensions/test/mochitest/file_style_redirect.css create mode 100644 toolkit/components/extensions/test/mochitest/file_tabs_permission_page1.html create mode 100644 toolkit/components/extensions/test/mochitest/file_tabs_permission_page2.html create mode 100644 toolkit/components/extensions/test/mochitest/file_third_party.html create mode 100644 toolkit/components/extensions/test/mochitest/file_to_drawWindow.html create mode 100644 toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect.html create mode 100644 toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html create mode 100644 toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html^headers^ create mode 100644 toolkit/components/extensions/test/mochitest/file_webNavigation_frameClientRedirect.html create mode 100644 toolkit/components/extensions/test/mochitest/file_webNavigation_frameRedirect.html create mode 100644 toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe.html create mode 100644 toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page1.html create mode 100644 toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page2.html create mode 100644 toolkit/components/extensions/test/mochitest/file_with_about_blank.html create mode 100644 toolkit/components/extensions/test/mochitest/file_with_images.html create mode 100644 toolkit/components/extensions/test/mochitest/file_with_subframes_and_embed.html create mode 100644 toolkit/components/extensions/test/mochitest/file_with_xorigin_frame.html create mode 100644 toolkit/components/extensions/test/mochitest/head.js create mode 100644 toolkit/components/extensions/test/mochitest/head_cookies.js create mode 100644 toolkit/components/extensions/test/mochitest/head_notifications.js create mode 100644 toolkit/components/extensions/test/mochitest/head_unlimitedStorage.js create mode 100644 toolkit/components/extensions/test/mochitest/head_webrequest.js create mode 100644 toolkit/components/extensions/test/mochitest/hsts.sjs create mode 100644 toolkit/components/extensions/test/mochitest/mochitest-common.toml create mode 100644 toolkit/components/extensions/test/mochitest/mochitest-remote.toml create mode 100644 toolkit/components/extensions/test/mochitest/mochitest-serviceworker.toml create mode 100644 toolkit/components/extensions/test/mochitest/mochitest.toml create mode 100644 toolkit/components/extensions/test/mochitest/mochitest_console.js create mode 100644 toolkit/components/extensions/test/mochitest/oauth.html create mode 100644 toolkit/components/extensions/test/mochitest/redirect_auto.sjs create mode 100644 toolkit/components/extensions/test/mochitest/redirection.sjs create mode 100644 toolkit/components/extensions/test/mochitest/return_headers.sjs create mode 100644 toolkit/components/extensions/test/mochitest/serviceWorker.js create mode 100644 toolkit/components/extensions/test/mochitest/slow_response.sjs create mode 100644 toolkit/components/extensions/test/mochitest/test_check_startupcache.html create mode 100644 toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_data_uri.html create mode 100644 toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_telemetry.html create mode 100644 toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_unrecognizedprop_warning.html create mode 100644 toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_open.html create mode 100644 toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_saveAs.html create mode 100644 toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_uniquify.html create mode 100644 toolkit/components/extensions/test/mochitest/test_chrome_ext_permissions.html create mode 100644 toolkit/components/extensions/test/mochitest/test_chrome_ext_svg_context_fill.html create mode 100644 toolkit/components/extensions/test/mochitest/test_chrome_ext_trackingprotection.html create mode 100644 toolkit/components/extensions/test/mochitest/test_chrome_ext_webnavigation_resolved_urls.html create mode 100644 toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_background_events.html create mode 100644 toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_host_permissions.html create mode 100644 toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_mozextension.html create mode 100644 toolkit/components/extensions/test/mochitest/test_chrome_native_messaging_paths.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_action.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_activityLog.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_all_apis.js create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_async_clipboard.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_background_canvas.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_background_page.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_background_page_dpi.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_browserAction_getUserSettings.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_browserAction_onClicked.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_incognito_window.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_windowId.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_without_pref.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_browserSettings_overrideDocumentColors.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_browsingData_indexedDB.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_browsingData_localStorage.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_browsingData_pluginData.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_browsingData_serviceWorkers.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_browsingData_settings.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_canvas_resistFingerprinting.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_clipboard.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_clipboard_image.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_contentscript_activeTab.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_contentscript_cache.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_contentscript_canvas.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_contentscript_devtools_metadata.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_contentscript_fission_frame.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_contentscript_getFrameId.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_contentscript_incognito.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_contentscript_permission.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_contentscript_securecontext.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_cookies.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_cookies_containers.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_cookies_expiry.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_cookies_first_party.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_cookies_incognito.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_bad.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_good.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_dnr_other_extensions.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_dnr_tabIds.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_dnr_upgradeScheme.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_downloads_download.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_embeddedimg_iframe_frameAncestors.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_exclude_include_globs.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_extension_iframe_messaging.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_external_messaging.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_generate.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_geolocation.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_identity.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_idle.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_inIncognitoContext_window.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_listener_proxies.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_new_tab_processType.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_notifications.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_optional_permissions.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_pageAction_onClicked.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_protocolHandlers.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_redirect_jar.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_request_urlClassification.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_runtime_connect.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_runtime_connect2.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_iframe.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_twoway.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_runtime_disconnect.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_script_filenames.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_scripting_contentScripts.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_activeTab.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_injectImmediately.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_scripting_insertCSS.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_scripting_permissions.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_scripting_removeCSS.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_sendmessage_doublereply.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_sendmessage_frameId.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_sendmessage_no_receiver.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply2.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_storage_cleanup.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_storage_manager_capabilities.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_storage_smoke_test.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_streamfilter_multiple.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_streamfilter_processswitch.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_subframes_privileges.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_tabs_captureTab.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_tabs_create_cookieStoreId.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_tabs_detectLanguage.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_tabs_executeScript_good.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_tabs_permissions.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_tabs_query_popup.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_test.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_web_accessible_incognito.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_webnavigation.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_webnavigation_filters.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_webnavigation_incognito.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_webrequest_and_proxy_filter.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_webrequest_background_events.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_webrequest_errors.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_webrequest_filter.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_webrequest_frameId.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_webrequest_getSecurityInfo.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_webrequest_hsts.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_bypass_cors.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_data_uri.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_webrequest_upgrade.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_webrequest_upload.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_webrequest_worker.html create mode 100644 toolkit/components/extensions/test/mochitest/test_ext_window_postMessage.html create mode 100644 toolkit/components/extensions/test/mochitest/test_startup_canary.html create mode 100644 toolkit/components/extensions/test/mochitest/test_verify_non_remote_mode.html create mode 100644 toolkit/components/extensions/test/mochitest/test_verify_remote_mode.html create mode 100644 toolkit/components/extensions/test/mochitest/test_verify_sw_mode.html create mode 100644 toolkit/components/extensions/test/mochitest/webrequest_chromeworker.js create mode 100644 toolkit/components/extensions/test/mochitest/webrequest_test.sys.mjs create mode 100644 toolkit/components/extensions/test/mochitest/webrequest_worker.js create mode 100644 toolkit/components/extensions/test/xpcshell/.eslintrc.js create mode 100644 toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherChild.sys.mjs create mode 100644 toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherParent.sys.mjs create mode 100644 toolkit/components/extensions/test/xpcshell/data/dummy_page.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/empty_file_download.txt create mode 100644 toolkit/components/extensions/test/xpcshell/data/file download.txt create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_WebRequest_page2.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.js create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.js create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_content_script_errors.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_csp.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_csp.html^headers^ create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_do_load_script_subresource.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_document_open.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_document_write.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_download.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_download.txt create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_iframe.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_image_bad.png create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_image_good.png create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_image_redirect.png create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_page_xhr.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_permission_xhr.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_privilege_escalation.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_sample.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_sample_registered_styles.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_script.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_script_bad.js create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_script_good.js create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_script_redirect.js create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_script_xhr.js create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_shadowdom.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_style_bad.css create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_style_good.css create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_style_redirect.css create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.css create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache_2.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_toplevel.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_with_iframe.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/file_with_xorigin_frame.html create mode 100644 toolkit/components/extensions/test/xpcshell/data/lorem.html.gz create mode 100644 toolkit/components/extensions/test/xpcshell/data/pixel_green.gif create mode 100644 toolkit/components/extensions/test/xpcshell/data/pixel_red.gif create mode 100644 toolkit/components/extensions/test/xpcshell/head.js create mode 100644 toolkit/components/extensions/test/xpcshell/head_dnr.js create mode 100644 toolkit/components/extensions/test/xpcshell/head_legacy_ep.js create mode 100644 toolkit/components/extensions/test/xpcshell/head_native_messaging.js create mode 100644 toolkit/components/extensions/test/xpcshell/head_remote.js create mode 100644 toolkit/components/extensions/test/xpcshell/head_schemas.js create mode 100644 toolkit/components/extensions/test/xpcshell/head_service_worker.js create mode 100644 toolkit/components/extensions/test/xpcshell/head_storage.js create mode 100644 toolkit/components/extensions/test/xpcshell/head_sync.js create mode 100644 toolkit/components/extensions/test/xpcshell/head_telemetry.js create mode 100644 toolkit/components/extensions/test/xpcshell/native_messaging.toml create mode 100644 toolkit/components/extensions/test/xpcshell/test_ExtensionShortcutKeyMap.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ExtensionStorageSync_migration_kinto.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_MatchPattern.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_QuarantinedDomains.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_QuarantinedDomains_telemetry.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_StorageSyncService.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_change_backgroundServiceWorker_enabled_pref_false.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_change_backgroundServiceWorker_enabled_pref_true.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_change_remote_mode.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_csp_validator.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_MessageManagerProxy.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_activityLog.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_private_field_xrays.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_xrays.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_alarms.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_alarms_does_not_fire.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_alarms_periodic.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_alarms_replaces.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_api_events_listener_calls_exceptions.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_asyncAPICall_isHandlingUserInput.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_background_api_injection.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_background_early_shutdown.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_background_generated_load_events.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_background_generated_reload.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_background_global_history.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_background_iframe.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_background_runtime_connect_params.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_background_script_and_service_worker.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_background_service_worker.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_background_sub_windows.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_background_teardown.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_background_type_module.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_background_window_properties.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_brokenlinks.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_browserSettings_homepage.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_browser_style_deprecation.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_browsingData.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cache.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cookieStoreId.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_cache_api.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_captivePortal_url.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_clear_cached_resources.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_about_blank_start.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_api_injection.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_async_loading.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_canvas_tainting.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context_isolation.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_create_iframe.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_css.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_dynamic_registration.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_errors.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_exporthelpers.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_importmap.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_in_background.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_json_api.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_module_import.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_perf_observers.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_change.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_fetch.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_scriptCreated.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_teardown.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_unregister_during_loadContentScript.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xml_prettyprint.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xorigin_frame.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xrays.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contexts.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contexts_gc.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities_move.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_cookieBehaviors.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_cookies_errors.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_cookies_firstParty.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_cookies_onChanged.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_cookies_partitionKey.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_cookies_samesite.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_cors_mozextension.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_csp_frame_ancestors.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_csp_upgrade_requests.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_debugging_utils.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_dnr_allowAllRequests.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_dnr_api.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_dnr_download.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_dnr_dynamic_rules.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_dnr_modifyHeaders.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_dnr_private_browsing.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_dnr_redirect_transform.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter_limits.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_dnr_session_rules.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_dnr_startup_cache.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_dnr_static_rules.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_dnr_system_restrictions.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_dnr_tabIds.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_dnr_testMatchOutcome.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_dnr_urlFilter.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_dnr_webrequest.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_dnr_without_webrequest.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_dns.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_downloads.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookieStoreId.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookies.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_downloads_eventpage.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_downloads_partitionKey.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_downloads_private.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_downloads_search.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_downloads_urlencoded.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_error_location.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_eventpage_idle.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_eventpage_messaging.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_eventpage_messaging_wakeup.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_eventpage_settings.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_eventpage_warning.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_experiments.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_extension.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_extension_content_telemetry.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_extension_page_navigated.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_failure.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_file_access.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_control.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_schema.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_geturl.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_i18n.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_idle.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_incognito.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_indexedDB_principal.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_ipcBlob.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_l10n.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_localStorage.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_management.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_manifest.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_chrome_version.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_opera_version.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_manifest_themes.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_messaging_startup.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_perf.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_unresponsive.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_networkStatus.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_notifications_incognito.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_notifications_unsupported.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_onmessage_removelistener.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_permission_xhr.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_permissions.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_permissions_api.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_permissions_migrate.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_permissions_uninstall.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_persistent_events.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_privacy.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_privacy_nonPersistentCookies.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_proxy_authorization_via_proxyinfo.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_proxy_config.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_proxy_containerIsolation.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_proxy_onauthrequired.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_proxy_settings.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_proxy_socks.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_proxy_speculative.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_proxy_startup.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_redirects.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_runtime_connect_no_receiver.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBackgroundPage.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBrowserInfo.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_runtime_getPlatformInfo.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_runtime_id.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_runtime_messaging_self.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports_gc.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_errors.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_multiple.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_no_receiver.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_same_site_cookies.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_same_site_redirects.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_sandbox_var.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_sandboxed_resource.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_schema.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_schemas.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_schemas_interactive.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_schemas_manifest_permissions.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_schemas_privileged.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_schemas_roots.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_schemas_versioned.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_script_filenames.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_css.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_file.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_scripting_mv2.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_scripting_persistAcrossSessions.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_scripting_startupCache.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_scripting_updateContentScripts.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_secfetch.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_shadowdom.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_shared_array_buffer.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_shared_workers.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_simple.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_startupData.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_startup_cache_telemetry.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_startup_perf.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_startup_request_handler.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_storage_content_local.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync_kinto.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_storage_idb_data_migration.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_storage_local.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_storage_managed.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_storage_managed_policy.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_storage_quota_exceeded_errors.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_storage_sanitizer.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_storage_session.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto_crypto.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_storage_tab.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_storage_telemetry.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_tab_teardown.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_telemetry.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_test_wrapper.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_theme_experiments.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_trustworthy_origin.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_unlimitedStorage.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_unload_frame.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_userScripts_exports.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_userScripts_register.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_wasm.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_auth.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cached.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cancelWithReason.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_containerIsolation.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_download.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_eventPage_StreamFilter.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterResponseData.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterTypes.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filter_urls.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_from_extension_page.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_host.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_incognito.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_mergecsp.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_permission.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirectProperty.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_StreamFilter.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_mozextension.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_requestSize.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_responseBody.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_restrictedHeaders.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_set_cookie.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup_StreamFilter.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_style_cache.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_suspend.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_urlclassification.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_userContextId.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource_StreamFilter.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webRequest_webSocket.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_webSocket.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources_matches.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_xhr_capabilities.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_xhr_cors.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_extension_permissions_migrate_kvstore_path.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_extension_permissions_migration.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_extension_process_alive.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_load_all_api_modules.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_locale_converter.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_locale_data.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_native_manifests.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_process_crash_telemetry.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_proxy_failover.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_proxy_incognito.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_proxy_info_results.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_proxy_listener.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_proxy_userContextId.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_resistfingerprinting_exempt.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_site_permissions.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_webRequest_ancestors.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_webRequest_cookies.js create mode 100644 toolkit/components/extensions/test/xpcshell/test_webRequest_filtering.js create mode 100644 toolkit/components/extensions/test/xpcshell/webidl-api/.eslintrc.js create mode 100644 toolkit/components/extensions/test/xpcshell/webidl-api/head_webidl_api.js create mode 100644 toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api.js create mode 100644 toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_event_callback.js create mode 100644 toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_request_handler.js create mode 100644 toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_errors.js create mode 100644 toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_formatters.js create mode 100644 toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_runtime_port.js create mode 100644 toolkit/components/extensions/test/xpcshell/webidl-api/xpcshell.toml create mode 100644 toolkit/components/extensions/test/xpcshell/xpcshell-common-e10s.toml create mode 100644 toolkit/components/extensions/test/xpcshell/xpcshell-common.toml create mode 100644 toolkit/components/extensions/test/xpcshell/xpcshell-content.toml create mode 100644 toolkit/components/extensions/test/xpcshell/xpcshell-e10s.toml create mode 100644 toolkit/components/extensions/test/xpcshell/xpcshell-legacy-ep.toml create mode 100644 toolkit/components/extensions/test/xpcshell/xpcshell-remote.toml create mode 100644 toolkit/components/extensions/test/xpcshell/xpcshell-serviceworker.toml create mode 100644 toolkit/components/extensions/test/xpcshell/xpcshell.toml create mode 100644 toolkit/components/extensions/tsconfig.json create mode 100644 toolkit/components/extensions/types/README.md create mode 100644 toolkit/components/extensions/types/XPCShellContentUtils.sys.d.mts create mode 100644 toolkit/components/extensions/types/extensions.ts create mode 100644 toolkit/components/extensions/types/gecko.ts create mode 100644 toolkit/components/extensions/types/globals.ts create mode 100644 toolkit/components/extensions/webidl-api/ExtensionAPI.cpp.in create mode 100644 toolkit/components/extensions/webidl-api/ExtensionAPI.h.in create mode 100644 toolkit/components/extensions/webidl-api/ExtensionAPI.webidl.in create mode 100644 toolkit/components/extensions/webidl-api/ExtensionAPIAddRemoveListener.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionAPIBase.cpp create mode 100644 toolkit/components/extensions/webidl-api/ExtensionAPIBase.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionAPICallAsyncFunction.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionAPICallFunctionNoReturn.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionAPICallSyncFunction.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionAPIGetProperty.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionAPIRequest.cpp create mode 100644 toolkit/components/extensions/webidl-api/ExtensionAPIRequest.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionAPIRequestForwarder.cpp create mode 100644 toolkit/components/extensions/webidl-api/ExtensionAPIRequestForwarder.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionAlarms.cpp create mode 100644 toolkit/components/extensions/webidl-api/ExtensionAlarms.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionBrowser.cpp create mode 100644 toolkit/components/extensions/webidl-api/ExtensionBrowser.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionBrowserSettings.cpp create mode 100644 toolkit/components/extensions/webidl-api/ExtensionBrowserSettings.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionBrowserSettingsColorManagement.cpp create mode 100644 toolkit/components/extensions/webidl-api/ExtensionBrowserSettingsColorManagement.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionDns.cpp create mode 100644 toolkit/components/extensions/webidl-api/ExtensionDns.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionEventListener.cpp create mode 100644 toolkit/components/extensions/webidl-api/ExtensionEventListener.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionEventManager.cpp create mode 100644 toolkit/components/extensions/webidl-api/ExtensionEventManager.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionMockAPI.cpp create mode 100644 toolkit/components/extensions/webidl-api/ExtensionMockAPI.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionPort.cpp create mode 100644 toolkit/components/extensions/webidl-api/ExtensionPort.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionProxy.cpp create mode 100644 toolkit/components/extensions/webidl-api/ExtensionProxy.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionRuntime.cpp create mode 100644 toolkit/components/extensions/webidl-api/ExtensionRuntime.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionScripting.cpp create mode 100644 toolkit/components/extensions/webidl-api/ExtensionScripting.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionSetting.cpp create mode 100644 toolkit/components/extensions/webidl-api/ExtensionSetting.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionTest.cpp create mode 100644 toolkit/components/extensions/webidl-api/ExtensionTest.h create mode 100644 toolkit/components/extensions/webidl-api/ExtensionWebIDL.conf create mode 100644 toolkit/components/extensions/webidl-api/GenerateWebIDLBindings.py create mode 100644 toolkit/components/extensions/webidl-api/InspectJSONSchema.py create mode 100644 toolkit/components/extensions/webidl-api/moz.build create mode 100644 toolkit/components/extensions/webidl-api/test/README.md create mode 100644 toolkit/components/extensions/webidl-api/test/conftest.py create mode 100644 toolkit/components/extensions/webidl-api/test/helpers.py create mode 100644 toolkit/components/extensions/webidl-api/test/python.toml create mode 100644 toolkit/components/extensions/webidl-api/test/test_all_schemas_smoketest.py create mode 100644 toolkit/components/extensions/webidl-api/test/test_json_schema_parsing.py create mode 100644 toolkit/components/extensions/webidl-api/test/test_json_schema_platform_diffs.py create mode 100644 toolkit/components/extensions/webidl-api/test/test_webidl_from_json_schema.py create mode 100644 toolkit/components/extensions/webrequest/ChannelWrapper.cpp create mode 100644 toolkit/components/extensions/webrequest/ChannelWrapper.h create mode 100644 toolkit/components/extensions/webrequest/PStreamFilter.ipdl create mode 100644 toolkit/components/extensions/webrequest/SecurityInfo.sys.mjs create mode 100644 toolkit/components/extensions/webrequest/StreamFilter.cpp create mode 100644 toolkit/components/extensions/webrequest/StreamFilter.h create mode 100644 toolkit/components/extensions/webrequest/StreamFilterBase.h create mode 100644 toolkit/components/extensions/webrequest/StreamFilterChild.cpp create mode 100644 toolkit/components/extensions/webrequest/StreamFilterChild.h create mode 100644 toolkit/components/extensions/webrequest/StreamFilterEvents.cpp create mode 100644 toolkit/components/extensions/webrequest/StreamFilterEvents.h create mode 100644 toolkit/components/extensions/webrequest/StreamFilterParent.cpp create mode 100644 toolkit/components/extensions/webrequest/StreamFilterParent.h create mode 100644 toolkit/components/extensions/webrequest/WebNavigationContent.cpp create mode 100644 toolkit/components/extensions/webrequest/WebNavigationContent.h create mode 100644 toolkit/components/extensions/webrequest/WebRequest.sys.mjs create mode 100644 toolkit/components/extensions/webrequest/WebRequestService.cpp create mode 100644 toolkit/components/extensions/webrequest/WebRequestService.h create mode 100644 toolkit/components/extensions/webrequest/WebRequestUpload.sys.mjs create mode 100644 toolkit/components/extensions/webrequest/components.conf create mode 100644 toolkit/components/extensions/webrequest/moz.build (limited to 'toolkit/components/extensions') diff --git a/toolkit/components/extensions/.eslintrc.js b/toolkit/components/extensions/.eslintrc.js new file mode 100644 index 0000000000..dfa4e2a7bf --- /dev/null +++ b/toolkit/components/extensions/.eslintrc.js @@ -0,0 +1,239 @@ +/* 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"; + +module.exports = { + globals: { + // These are defined in the WebExtension script scopes by ExtensionCommon.jsm + Cc: true, + Ci: true, + Cr: true, + Cu: true, + AppConstants: true, + ExtensionAPI: true, + ExtensionAPIPersistent: true, + ExtensionCommon: true, + ExtensionUtils: true, + extensions: true, + global: true, + require: false, + Services: true, + XPCOMUtils: true, + }, + + rules: { + // Rules from the mozilla plugin + "mozilla/balanced-listeners": "error", + "mozilla/no-aArgs": "error", + "mozilla/var-only-at-top-level": "error", + // Disable reject-importGlobalProperties because we don't want to include + // these in the sandbox directly as that would potentially mean the + // imported properties would be instatiated up-front rather than lazily. + "mozilla/reject-importGlobalProperties": "off", + + // Functions are not required to consistently return something or nothing + "consistent-return": "off", + + // Disallow empty statements. This will report an error for: + // try { something(); } catch (e) {} + // but will not report it for: + // try { something(); } catch (e) { /* Silencing the error because ...*/ } + // which is a valid use case. + "no-empty": "error", + + // No expressions where a statement is expected + "no-unused-expressions": "error", + + // No declaring variables that are never used + "no-unused-vars": [ + "error", + { + args: "none", + vars: "all", + varsIgnorePattern: "^console$", + }, + ], + + // No using things before they're defined. + "no-use-before-define": [ + "error", + { + allowNamedExports: true, + classes: true, + // The next two being false allows idiomatic patterns which are more + // type-inference friendly. Functions are hoisted, so this is safe. + functions: false, + // This flag is only meaningful for `var` declarations. + // When false, it still disallows use-before-define in the same scope. + // Since we only allow `var` at the global scope, this is no worse than + // how we currently declare an uninitialized `let` at the top of file. + variables: false, + }, + ], + + // Disallow using variables outside the blocks they are defined (especially + // since only let and const are used, see "no-var"). + "block-scoped-var": "error", + + // Warn about cyclomatic complexity in functions. + complexity: "error", + + // Don't warn for inconsistent naming when capturing this (not so important + // with auto-binding fat arrow functions). + // "consistent-this": ["error", "self"], + + // Don't require a default case in switch statements. Avoid being forced to + // add a bogus default when you know all possible cases are handled. + "default-case": "off", + + // Allow using == instead of ===, in the interest of landing something since + // the devtools codebase is split on convention here. + eqeqeq: "off", + + // Don't require function expressions to have a name. + // This makes the code more verbose and hard to read. Our engine already + // does a fantastic job assigning a name to the function, which includes + // the enclosing function name, and worst case you have a line number that + // you can just look up. + "func-names": "off", + + // Allow use of function declarations and expressions. + "func-style": "off", + + // Maximum depth callbacks can be nested. + "max-nested-callbacks": ["error", 4], + + // Don't limit the number of parameters that can be used in a function. + "max-params": "off", + + // Don't limit the maximum number of statement allowed in a function. We + // already have the complexity rule that's a better measurement. + "max-statements": "off", + + // Don't require a capital letter for constructors, only check if all new + // operators are followed by a capital letter. Don't warn when capitalized + // functions are used without the new operator. + "new-cap": ["off", { capIsNew: false }], + + // Allow use of bitwise operators. + "no-bitwise": "off", + + // Disallow using the console API. + "no-console": "error", + + // Allow using constant expressions in conditions like while (true) + "no-constant-condition": "off", + + // Allow use of the continue statement. + "no-continue": "off", + + // Allow division operators explicitly at beginning of regular expression. + "no-div-regex": "off", + + // Disallow adding to native types + "no-extend-native": "error", + + // Allow comments inline after code. + "no-inline-comments": "off", + + // Disallow use of labels for anything other then loops and switches. + "no-labels": ["error", { allowLoop: true }], + + // Disallow use of multiline strings (use template strings instead). + "no-multi-str": "error", + + // Allow reassignment of function parameters. + "no-param-reassign": "off", + + // Allow string concatenation with __dirname and __filename (not a node env). + "no-path-concat": "off", + + // Allow use of unary operators, ++ and --. + "no-plusplus": "off", + + // Allow using process.env (not a node environment). + "no-process-env": "off", + + // Allow using process.exit (not a node environment). + "no-process-exit": "off", + + // Disallow usage of __proto__ property. + "no-proto": "error", + + // Don't restrict usage of specified node modules (not a node environment). + "no-restricted-modules": "off", + + // Disallow use of assignment in return statement. It is preferable for a + // single line of code to have only one easily predictable effect. + "no-return-assign": "error", + + // Don't warn about declaration of variables already declared in the outer scope. + "no-shadow": "off", + + // Allow use of synchronous methods (not a node environment). + "no-sync": "off", + + // Allow the use of ternary operators. + "no-ternary": "off", + + // Allow dangling underscores in identifiers (for privates). + "no-underscore-dangle": "off", + + // Allow use of undefined variable. + "no-undefined": "off", + + // We use var-only-at-top-level instead of no-var as we allow top level + // vars. + "no-var": "off", + + // Allow using TODO/FIXME comments. + "no-warning-comments": "off", + + // Don't require method and property shorthand syntax for object literals. + // We use this in the code a lot, but not consistently, and this seems more + // like something to check at code review time. + "object-shorthand": "off", + + // Allow more than one variable declaration per function. + "one-var": "off", + + // Require use of the second argument for parseInt(). + radix: "error", + + // Don't require to sort variables within the same declaration block. + // Anyway, one-var is disabled. + "sort-vars": "off", + + // Require "use strict" to be defined globally in the script. + strict: ["error", "global"], + + // Allow vars to be declared anywhere in the scope. + "vars-on-top": "off", + + // Disallow Yoda conditions (where literal value comes first). + yoda: "error", + + // Disallow function or variable declarations in nested blocks + "no-inner-declarations": "error", + + // Disallow labels that share a name with a variable + "no-label-var": "error", + }, + + overrides: [ + { + files: "test/xpcshell/head*.js", + rules: { + "no-unused-vars": [ + "error", + { + args: "none", + vars: "local", + }, + ], + }, + }, + ], +}; diff --git a/toolkit/components/extensions/ConduitsChild.sys.mjs b/toolkit/components/extensions/ConduitsChild.sys.mjs new file mode 100644 index 0000000000..c5774ab39c --- /dev/null +++ b/toolkit/components/extensions/ConduitsChild.sys.mjs @@ -0,0 +1,216 @@ +/* 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/. */ + +/** + * This @file implements the child side of Conduits, an abstraction over + * Fission IPC for extension API subject. See {@link ConduitsParent.jsm} + * for more details about the overall design. + * + * @typedef {object} MessageData + * @property {ConduitID} [target] + * @property {ConduitID} [sender] + * @property {boolean} query + * @property {object} arg + */ + +/** + * Base class for both child (Point) and parent (Broadcast) side of conduits, + * handles setting up send/receive method stubs. + */ +export class BaseConduit { + /** + * @param {object} subject + * @param {ConduitAddress} address + */ + constructor(subject, address) { + this.subject = subject; + this.address = address; + this.id = address.id; + + for (let name of address.send || []) { + this[`send${name}`] = this._send.bind(this, name, false); + } + for (let name of address.query || []) { + this[`query${name}`] = this._send.bind(this, name, true); + } + + this.recv = new Map(); + for (let name of address.recv || []) { + let method = this.subject[`recv${name}`]; + if (!method) { + throw new Error(`recv${name} not found for conduit ${this.id}`); + } + this.recv.set(name, method.bind(this.subject)); + } + } + + /** + * Internal, partially @abstract, uses the actor to send the message/query. + * + * @param {string} method + * @param {boolean} query Flag indicating a response is expected. + * @param {JSWindowActor} actor + * @param {MessageData} data + * @returns {Promise?} + */ + _send(method, query, actor, data) { + if (query) { + return actor.sendQuery(method, data); + } + actor.sendAsyncMessage(method, data); + } + + /** + * Internal, calls the specific recvX method based on the message. + * + * @param {string} name Message/method name. + * @param {object} arg Message data, the one and only method argument. + * @param {object} meta Metadata about the method call. + */ + async _recv(name, arg, meta) { + let method = this.recv.get(name); + if (!method) { + throw new Error(`recv${name} not found for conduit ${this.id}`); + } + try { + return await method(arg, meta); + } catch (e) { + if (meta.query) { + return Promise.reject(e); + } + Cu.reportError(e); + } + } +} + +/** + * Child side conduit, can only send/receive point-to-point messages via the + * one specific ConduitsChild actor. + */ +export class PointConduit extends BaseConduit { + constructor(subject, address, actor) { + super(subject, address); + this.actor = actor; + this.actor.sendAsyncMessage("ConduitOpened", { arg: address }); + } + + /** + * Internal, sends messages via the actor, used by sendX stubs. + * + * @param {string} method + * @param {boolean} query + * @param {object?} arg + * @returns {Promise?} + */ + _send(method, query, arg = {}) { + if (!this.actor) { + throw new Error(`send${method} on closed conduit ${this.id}`); + } + let sender = this.id; + return super._send(method, query, this.actor, { arg, query, sender }); + } + + /** + * Closes the conduit from further IPC, notifies the parent side by default. + * + * @param {boolean} silent + */ + close(silent = false) { + let { actor } = this; + if (actor) { + this.actor = null; + actor.conduits.delete(this.id); + if (!silent) { + // Catch any exceptions that can occur if the conduit is closed while + // the actor is being destroyed due to the containing browser being closed. + // This should be treated as if the silent flag was passed. + try { + actor.sendAsyncMessage("ConduitClosed", { sender: this.id }); + } catch (ex) {} + } + } + this.closeCallback?.(); + this.closeCallback = null; + } + + /** + * Set the callback to be called when the conduit is closed. + * + * @param {Function} callback + */ + setCloseCallback(callback) { + this.closeCallback = callback; + } +} + +/** + * Implements the child side of the Conduits actor, manages conduit lifetimes. + */ +export class ConduitsChild extends JSWindowActorChild { + constructor() { + super(); + this.conduits = new Map(); + } + + /** + * Public entry point a child-side subject uses to open a conduit. + * + * @param {object} subject + * @param {ConduitAddress} address + * @returns {PointConduit} + */ + openConduit(subject, address) { + let conduit = new PointConduit(subject, address, this); + this.conduits.set(conduit.id, conduit); + return conduit; + } + + /** + * JSWindowActor method, routes the message to the target subject. + * + * @param {object} options + * @param {string} options.name + * @param {MessageData | MessageData[]} options.data + * @returns {Promise?} + */ + receiveMessage({ name, data }) { + // Batch of webRequest events, run each and return results, ignoring errors. + if (Array.isArray(data)) { + let run = data => this.receiveMessage({ name, data }); + return Promise.all(data.map(data => run(data).catch(Cu.reportError))); + } + + let { target, arg, query, sender } = data; + let conduit = this.conduits.get(target); + if (!conduit) { + throw new Error(`${name} for closed conduit ${target}: ${uneval(arg)}`); + } + return conduit._recv(name, arg, { sender, query, actor: this }); + } + + /** + * JSWindowActor method, ensure cleanup. + */ + didDestroy() { + for (let conduit of this.conduits.values()) { + conduit.close(true); + } + this.conduits.clear(); + } +} + +/** + * Child side of the Conduits process actor. Same code as JSWindowActor. + */ +export class ProcessConduitsChild extends JSProcessActorChild { + constructor() { + super(); + this.conduits = new Map(); + } + + openConduit = ConduitsChild.prototype.openConduit; + receiveMessage = ConduitsChild.prototype.receiveMessage; + willDestroy = ConduitsChild.prototype.willDestroy; + didDestroy = ConduitsChild.prototype.didDestroy; +} diff --git a/toolkit/components/extensions/ConduitsParent.sys.mjs b/toolkit/components/extensions/ConduitsParent.sys.mjs new file mode 100644 index 0000000000..d90bc4afd7 --- /dev/null +++ b/toolkit/components/extensions/ConduitsParent.sys.mjs @@ -0,0 +1,487 @@ +/* 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/. */ + +/** + * This @file implements the parent side of Conduits, an abstraction over + * Fission IPC for extension Contexts, API managers, Ports/Messengers, and + * other types of "subjects" participating in implementation of extension APIs. + * + * Additionally, knowledge about conduits from all child processes is gathered + * here, and used together with the full CanonicalBrowsingContext tree to route + * IPC messages and queries directly to the right subjects. + * + * Each Conduit is tied to one subject, attached to a ConduitAddress descriptor, + * and exposes an API for sending/receiving via an actor, or multiple actors in + * case of the parent process. + * + * @typedef {number|string} ConduitID + * + * @typedef {object} ConduitAddress + * @property {ConduitID} [id] Globally unique across all processes. + * @property {string[]} [recv] + * @property {string[]} [send] + * @property {string[]} [query] + * @property {string[]} [cast] + * + * @property {*} [actor] + * @property {boolean} [verified] + * @property {string} [url] + * @property {number} [frameId] + * @property {string} [workerScriptURL] + * @property {string} [extensionId] + * @property {string} [envType] + * @property {string} [instanceId] + * @property {number} [portId] + * @property {boolean} [native] + * @property {boolean} [source] + * @property {string} [reportOnClosed] + * + * Lists of recvX, sendX, queryX and castX methods this subject will use. + * + * @typedef {"messenger"|"port"|"tab"} BroadcastKind + * Kinds of broadcast targeting filters. + * + * @example + * ```js + * { + * init(actor) { + * this.conduit = actor.openConduit(this, { + * id: this.id, + * recv: ["recvAddNumber"], + * send: ["sendNumberUpdate"], + * }); + * }, + * + * recvAddNumber({ num }, { actor, sender }) { + * num += 1; + * this.conduit.sendNumberUpdate(sender.id, { num }); + * } + * } + * ``` + */ + +import { BaseConduit } from "resource://gre/modules/ConduitsChild.sys.mjs"; +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; +import { WebNavigationFrames } from "resource://gre/modules/WebNavigationFrames.sys.mjs"; + +const { DefaultWeakMap, ExtensionError } = ExtensionUtils; + +const BATCH_TIMEOUT_MS = 250; +const ADDON_ENV = new Set(["addon_child", "devtools_child"]); + +/** + * Internal, keeps track of all parent and remote (child) conduits. + */ +const Hub = { + /** @type {Map} Info about all child conduits. */ + remotes: new Map(), + + /** @type {Map} All open parent conduits. */ + conduits: new Map(), + + /** @type {Map} Parent conduits by recvMethod. */ + byMethod: new Map(), + + /** @type {WeakMap>} Conduits by actor. */ + byActor: new DefaultWeakMap(() => new Set()), + + /** @type {Map} */ + reportOnClosed: new Map(), + + /** + * Save info about a new parent conduit, register it as a global listener. + * + * @param {BroadcastConduit} conduit + */ + openConduit(conduit) { + this.conduits.set(conduit.id, conduit); + for (let name of conduit.address.recv || []) { + if (this.byMethod.get(name)) { + // For now, we only allow one parent conduit handling each recv method. + throw new Error(`Duplicate BroadcastConduit method name recv${name}`); + } + this.byMethod.set(name, conduit); + } + }, + + /** + * Cleanup. + * + * @param {BroadcastConduit} conduit + */ + closeConduit({ id, address }) { + this.conduits.delete(id); + for (let name of address.recv || []) { + this.byMethod.delete(name); + } + }, + + /** + * Confirm that a remote conduit comes from an extension background + * service worker. + * + * @see ExtensionPolicyService::CheckParentFrames + * @param {ConduitAddress} remote + * @returns {boolean} + */ + verifyWorkerEnv({ actor, extensionId, workerScriptURL }) { + const addonPolicy = WebExtensionPolicy.getByID(extensionId); + if (!addonPolicy) { + throw new Error(`No WebExtensionPolicy found for ${extensionId}`); + } + if (actor.manager.remoteType !== addonPolicy.extension.remoteType) { + throw new Error( + `Bad ${extensionId} process: ${actor.manager.remoteType}` + ); + } + if (!addonPolicy.isManifestBackgroundWorker(workerScriptURL)) { + throw new Error( + `Bad ${extensionId} background service worker script url: ${workerScriptURL}` + ); + } + return true; + }, + + /** + * Confirm that a remote conduit comes from an extension page or + * an extension background service worker. + * + * @see ExtensionPolicyService::CheckParentFrames + * @param {ConduitAddress} remote + * @returns {boolean} + */ + verifyEnv({ actor, envType, extensionId, ...rest }) { + if (!extensionId || !ADDON_ENV.has(envType)) { + return false; + } + + // ProcessConduit related to a background service worker context. + if (actor.manager && actor.manager instanceof Ci.nsIDOMProcessParent) { + return this.verifyWorkerEnv({ actor, envType, extensionId, ...rest }); + } + + let windowGlobal = actor.manager; + + while (windowGlobal) { + let { browsingContext: bc, documentPrincipal: prin } = windowGlobal; + + if (prin.addonId !== extensionId) { + throw new Error(`Bad ${extensionId} principal: ${prin.URI.spec}`); + } + if (bc.currentRemoteType !== prin.addonPolicy.extension.remoteType) { + throw new Error(`Bad ${extensionId} process: ${bc.currentRemoteType}`); + } + + if (!bc.parent) { + return true; + } + windowGlobal = bc.embedderWindowGlobal; + } + throw new Error(`Missing WindowGlobalParent for ${extensionId}`); + }, + + /** + * Fill in common address fields knowable from the parent process. + * + * @param {ConduitAddress} address + * @param {ConduitsParent} actor + */ + fillInAddress(address, actor) { + address.actor = actor; + address.verified = this.verifyEnv(address); + if (JSWindowActorParent.isInstance(actor)) { + address.frameId = WebNavigationFrames.getFrameId(actor.browsingContext); + address.url = actor.manager.documentURI?.spec; + } else { + // Background service worker contexts do not have an associated frame + // and there is no browsingContext to retrieve the expected url from. + // + // WorkerContextChild sent in the address part of the ConduitOpened request + // the worker script URL as address.workerScriptURL, and so we can use that + // as the address.url too. + address.frameId = -1; + address.url = address.workerScriptURL; + } + }, + + /** + * Save info about a new remote conduit. + * + * @param {ConduitAddress} address + * @param {ConduitsParent} actor + */ + recvConduitOpened(address, actor) { + this.fillInAddress(address, actor); + this.remotes.set(address.id, address); + this.byActor.get(actor).add(address); + }, + + /** + * Notifies listeners and cleans up after the remote conduit is closed. + * + * @param {ConduitAddress} remote + */ + recvConduitClosed(remote) { + this.remotes.delete(remote.id); + this.byActor.get(remote.actor).delete(remote); + + remote.actor = null; + for (let [key, conduit] of Hub.reportOnClosed.entries()) { + if (remote[key]) { + conduit.subject.recvConduitClosed(remote); + } + } + }, + + /** + * Close all remote conduits when the actor goes away. + * + * @param {ConduitsParent} actor + */ + actorClosed(actor) { + for (let remote of this.byActor.get(actor)) { + // When a Port is closed, we notify the other side, but it might share + // an actor, so we shouldn't sendQeury() in that case (see bug 1623976). + this.remotes.delete(remote.id); + } + for (let remote of this.byActor.get(actor)) { + this.recvConduitClosed(remote); + } + this.byActor.delete(actor); + }, +}; + +/** + * Parent side conduit, registers as a global listeners for certain messages, + * and can target specific child conduits when sending. + */ +export class BroadcastConduit extends BaseConduit { + /** + * @param {object} subject + * @param {ConduitAddress} address + */ + constructor(subject, address) { + super(subject, address); + + // Create conduit.castX() bidings. + for (let name of address.cast || []) { + this[`cast${name}`] = this._cast.bind(this, name); + } + + // Wants to know when conduits with a specific attribute are closed. + // `subject.recvConduitClosed(address)` method will be called. + if (address.reportOnClosed) { + Hub.reportOnClosed.set(address.reportOnClosed, this); + } + + this.open = true; + Hub.openConduit(this); + } + + /** + * Internal, sends a message to a specific conduit, used by sendX stubs. + * + * @param {string} method + * @param {boolean} query + * @param {ConduitID} target + * @param {object?} arg + * @returns {Promise} + */ + _send(method, query, target, arg = {}) { + if (!this.open) { + throw new Error(`send${method} on closed conduit ${this.id}`); + } + + let sender = this.id; + let { actor } = Hub.remotes.get(target); + + if (method === "RunListener" && arg.path.startsWith("webRequest.")) { + return actor.batch(method, { target, arg, query, sender }); + } + return super._send(method, query, actor, { target, arg, query, sender }); + } + + /** + * Broadcasts a method call to all conduits of kind that satisfy filtering by + * kind-specific properties from arg, returns an array of response promises. + * + * @param {string} method + * @param {BroadcastKind} kind + * @param {object} arg + * @returns {Promise | Promise} + */ + _cast(method, kind, arg) { + let filters = { + // Target Ports by portId and side (connect caller/onConnect receiver). + port: remote => + remote.portId === arg.portId && + (arg.source == null || remote.source === arg.source), + + // Target Messengers in extension pages by extensionId and envType. + messenger: r => + r.verified && + r.id !== arg.sender.contextId && + r.extensionId === arg.extensionId && + r.recv.includes(method) && + // TODO: Bug 1453343 - get rid of this: + (r.envType === "addon_child" || arg.sender.envType !== "content_child"), + + // Target Messengers by extensionId, tabId (topBC) and frameId. + tab: remote => + remote.extensionId === arg.extensionId && + remote.actor.manager.browsingContext?.top.id === arg.topBC && + (arg.frameId == null || remote.frameId === arg.frameId) && + remote.recv.includes(method), + + // Target Messengers by extensionId. + extension: remote => remote.instanceId === arg.instanceId, + }; + + let targets = Array.from(Hub.remotes.values()).filter(filters[kind]); + let promises = targets.map(c => this._send(method, true, c.id, arg)); + + return arg.firstResponse + ? this._raceResponses(promises) + : Promise.allSettled(promises); + } + + /** + * Custom Promise.race() function that ignores certain resolutions and errors. + * + * @typedef {{response?: any, received?: boolean}} Response + * + * @param {Promise[]} promises + * @returns {Promise} + */ + _raceResponses(promises) { + return new Promise((resolve, reject) => { + let result; + promises.map(p => + p + .then(value => { + if (value.response) { + // We have an explicit response, resolve immediately. + resolve(value); + } else if (value.received) { + // Message was received, but no response. + // Resolve with this only if there is no other explicit response. + result = value; + } + }) + .catch(err => { + // Forward errors that are exposed to extension, but ignore + // internal errors such as actor destruction and DataCloneError. + if (err instanceof ExtensionError || err?.mozWebExtLocation) { + reject(err); + } else { + Cu.reportError(err); + } + }) + ); + // Ensure resolving when there are no responses. + Promise.allSettled(promises).then(() => resolve(result)); + }); + } + + async close() { + this.open = false; + Hub.closeConduit(this); + } +} + +/** + * Implements the parent side of the Conduits actor. + */ +export class ConduitsParent extends JSWindowActorParent { + constructor() { + super(); + this.batchData = []; + this.batchPromise = null; + this.batchResolve = null; + this.timerActive = false; + } + + /** + * Group webRequest events to send them as a batch, reducing IPC overhead. + * + * @param {string} name + * @param {import("ConduitsChild.sys.mjs").MessageData} data + * @returns {Promise} + */ + batch(name, data) { + let pos = this.batchData.length; + this.batchData.push(data); + + let sendNow = idleDispatch => { + if (this.batchData.length && this.manager) { + this.batchResolve(this.sendQuery(name, this.batchData)); + } else { + this.batchResolve([]); + } + this.batchData = []; + this.timerActive = !idleDispatch; + }; + + if (!pos) { + this.batchPromise = new Promise(r => (this.batchResolve = r)); + if (!this.timerActive) { + ChromeUtils.idleDispatch(sendNow, { timeout: BATCH_TIMEOUT_MS }); + this.timerActive = true; + } + } + + if (data.arg.urgentSend) { + // If this is an urgent blocking event, run this batch right away. + sendNow(false); + } + + return this.batchPromise.then(results => results[pos]); + } + + /** + * JSWindowActor method, routes the message to the target subject. + * + * @param {object} options + * @param {string} options.name + * @param {import("ConduitsChild.sys.mjs").MessageData} options.data + * @returns {Promise?} + */ + async receiveMessage({ name, data: { arg, query, sender } }) { + if (name === "ConduitOpened") { + return Hub.recvConduitOpened(arg, this); + } + + let remote = Hub.remotes.get(sender); + if (!remote || remote.actor !== this) { + throw new Error(`Unknown sender or wrong actor for recv${name}`); + } + + if (name === "ConduitClosed") { + return Hub.recvConduitClosed(remote); + } + + let conduit = Hub.byMethod.get(name); + if (!conduit) { + throw new Error(`Parent conduit for recv${name} not found`); + } + + return conduit._recv(name, arg, { actor: this, query, sender: remote }); + } + + /** + * JSWindowActor method, ensure cleanup. + */ + didDestroy() { + Hub.actorClosed(this); + } +} + +/** + * Parent side of the Conduits process actor. Same code as JSWindowActor. + */ +export class ProcessConduitsParent extends JSProcessActorParent { + receiveMessage = ConduitsParent.prototype.receiveMessage; + willDestroy = ConduitsParent.prototype.willDestroy; + didDestroy = ConduitsParent.prototype.didDestroy; +} diff --git a/toolkit/components/extensions/DocumentObserver.h b/toolkit/components/extensions/DocumentObserver.h new file mode 100644 index 0000000000..b9b0dc6f78 --- /dev/null +++ b/toolkit/components/extensions/DocumentObserver.h @@ -0,0 +1,59 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#ifndef mozilla_extensions_DocumentObserver_h +#define mozilla_extensions_DocumentObserver_h + +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/MozDocumentObserverBinding.h" + +#include "mozilla/extensions/WebExtensionContentScript.h" + +class nsILoadInfo; +class nsPIDOMWindowOuter; + +namespace mozilla { +namespace extensions { + +class DocumentObserver final : public nsISupports, public nsWrapperCache { + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(DocumentObserver) + + static already_AddRefed Constructor( + dom::GlobalObject& aGlobal, dom::MozDocumentCallback& aCallbacks); + + void Observe(const dom::Sequence>& matchers, + ErrorResult& aRv); + + void Disconnect(); + + const nsTArray>& Matchers() const { + return mMatchers; + } + + void NotifyMatch(MozDocumentMatcher& aMatcher, nsPIDOMWindowOuter* aWindow); + void NotifyMatch(MozDocumentMatcher& aMatcher, nsILoadInfo* aLoadInfo); + + nsISupports* GetParentObject() const { return mParent; } + JSObject* WrapObject(JSContext* aCx, + JS::Handle aGivenProto) override; + + protected: + virtual ~DocumentObserver() = default; + + private: + explicit DocumentObserver(nsISupports* aParent, + dom::MozDocumentCallback& aCallbacks) + : mParent(aParent), mCallbacks(&aCallbacks) {} + + nsCOMPtr mParent; + RefPtr mCallbacks; + nsTArray> mMatchers; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_DocumentObserver_h diff --git a/toolkit/components/extensions/Extension.sys.mjs b/toolkit/components/extensions/Extension.sys.mjs new file mode 100644 index 0000000000..4bbaa56199 --- /dev/null +++ b/toolkit/components/extensions/Extension.sys.mjs @@ -0,0 +1,4088 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +/* + * This file is the main entry point for extensions. When an extension + * loads, its bootstrap.js file creates a Extension instance + * and calls .startup() on it. It calls .shutdown() when the extension + * unloads. Extension manages any extension-specific state in + * the chrome process. + * + * TODO(rpl): we are current restricting the extensions to a single process + * (set as the current default value of the "dom.ipc.processCount.extension" + * preference), if we switch to use more than one extension process, we have to + * be sure that all the browser's frameLoader are associated to the same process, + * e.g. by enabling the `maychangeremoteness` attribute, and/or setting + * `initialBrowsingContextGroupId` attribute to the correct value. + * + * At that point we are going to keep track of the existing browsers associated to + * a webextension to ensure that they are all running in the same process (and we + * are also going to do the same with the browser element provided to the + * addon debugging Remote Debugging actor, e.g. because the addon has been + * reloaded by the user, we have to ensure that the new extension pages are going + * to run in the same process of the existing addon debugging browser element). + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; +import { ExtensionParent } from "resource://gre/modules/ExtensionParent.sys.mjs"; +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs", + AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs", + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", + ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs", + ExtensionDNRStore: "resource://gre/modules/ExtensionDNRStore.sys.mjs", + ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs", + ExtensionPreferencesManager: + "resource://gre/modules/ExtensionPreferencesManager.sys.mjs", + ExtensionProcessScript: + "resource://gre/modules/ExtensionProcessScript.sys.mjs", + ExtensionScriptingStore: + "resource://gre/modules/ExtensionScriptingStore.sys.mjs", + ExtensionStorage: "resource://gre/modules/ExtensionStorage.sys.mjs", + ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.sys.mjs", + ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs", + LightweightThemeManager: + "resource://gre/modules/LightweightThemeManager.sys.mjs", + Log: "resource://gre/modules/Log.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + SITEPERMS_ADDON_TYPE: + "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", + ServiceWorkerCleanUp: "resource://gre/modules/ServiceWorkerCleanUp.sys.mjs", + extensionStorageSync: "resource://gre/modules/ExtensionStorageSync.sys.mjs", + PERMISSION_L10N: "resource://gre/modules/ExtensionPermissionMessages.sys.mjs", + permissionToL10nId: + "resource://gre/modules/ExtensionPermissionMessages.sys.mjs", + QuarantinedDomains: "resource://gre/modules/ExtensionPermissions.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "resourceProtocol", () => + Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler) +); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + aomStartup: [ + "@mozilla.org/addons/addon-manager-startup;1", + "amIAddonManagerStartup", + ], + spellCheck: ["@mozilla.org/spellchecker/engine;1", "mozISpellCheckingEngine"], +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "processCount", + "dom.ipc.processCount.extension" +); + +// Temporary pref to be turned on when ready. +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "userContextIsolation", + "extensions.userContextIsolation.enabled", + false +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "userContextIsolationDefaultRestricted", + "extensions.userContextIsolation.defaults.restricted", + "[]" +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "dnrEnabled", + "extensions.dnr.enabled", + true +); + +// This pref modifies behavior for MV2. MV3 is enabled regardless. +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "eventPagesEnabled", + "extensions.eventPages.enabled" +); + +// This pref is used to check if storage.sync is still the Kinto-based backend +// (GeckoView should be the only one still using it). +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "storageSyncOldKintoBackend", + "webextensions.storage.sync.kinto", + false +); + +// Deprecation of browser_style, through .supported & .same_as_mv2 prefs: +// - true true = warn only: deprecation message only (no behavioral changes). +// - true false = deprecate: default to false, even if default was true in MV2. +// - false = remove: always use false, even when true is specified. +// (if .same_as_mv2 is set, also warn if the default changed) +// Deprecation plan: https://bugzilla.mozilla.org/show_bug.cgi?id=1827910#c1 +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "browserStyleMV3supported", + "extensions.browser_style_mv3.supported", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "browserStyleMV3sameAsMV2", + "extensions.browser_style_mv3.same_as_mv2", + false +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "processCrashThreshold", + "extensions.webextensions.crash.threshold", + // The default number of times an extension process is allowed to crash + // within a timeframe. + 5 +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "processCrashTimeframe", + "extensions.webextensions.crash.timeframe", + // The default timeframe used to count crashes, in milliseconds. + 30 * 1000 +); + +var { + GlobalManager, + IconDetails, + ParentAPIManager, + StartupCache, + apiManager: Management, +} = ExtensionParent; + +export { Management }; + +const { getUniqueId, promiseTimeout } = ExtensionUtils; + +const { EventEmitter, redefineGetter, updateAllowedOrigins } = ExtensionCommon; + +ChromeUtils.defineLazyGetter( + lazy, + "LocaleData", + () => ExtensionCommon.LocaleData +); + +ChromeUtils.defineLazyGetter(lazy, "NO_PROMPT_PERMISSIONS", async () => { + // Wait until all extension API schemas have been loaded and parsed. + await Management.lazyInit(); + return new Set( + lazy.Schemas.getPermissionNames([ + "PermissionNoPrompt", + "OptionalPermissionNoPrompt", + "PermissionPrivileged", + ]) + ); +}); + +// TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed. +ChromeUtils.defineLazyGetter(lazy, "SCHEMA_SITE_PERMISSIONS", async () => { + // Wait until all extension API schemas have been loaded and parsed. + await Management.lazyInit(); + return lazy.Schemas.getPermissionNames(["SitePermission"]); +}); + +const { sharedData } = Services.ppmm; + +const PRIVATE_ALLOWED_PERMISSION = "internal:privateBrowsingAllowed"; +const SVG_CONTEXT_PROPERTIES_PERMISSION = + "internal:svgContextPropertiesAllowed"; + +// The userContextID reserved for the extension storage (its purpose is ensuring that the IndexedDB +// storage used by the browser.storage.local API is not directly accessible from the extension code, +// it is defined and reserved as "userContextIdInternal.webextStorageLocal" in ContextualIdentityService.sys.mjs). +const WEBEXT_STORAGE_USER_CONTEXT_ID = -1 >>> 0; + +// The maximum time to wait for extension child shutdown blockers to complete. +const CHILD_SHUTDOWN_TIMEOUT_MS = 8000; + +// Permissions that are only available to privileged extensions. +const PRIVILEGED_PERMS = new Set([ + "activityLog", + "mozillaAddons", + "networkStatus", + "normandyAddonStudy", + "telemetry", +]); + +const PRIVILEGED_PERMS_ANDROID_ONLY = new Set([ + "geckoViewAddons", + "nativeMessagingFromContent", + "nativeMessaging", +]); + +const PRIVILEGED_PERMS_DESKTOP_ONLY = new Set(["normandyAddonStudy"]); + +if (AppConstants.platform == "android") { + for (const perm of PRIVILEGED_PERMS_ANDROID_ONLY) { + PRIVILEGED_PERMS.add(perm); + } +} + +if ( + AppConstants.MOZ_APP_NAME != "firefox" || + AppConstants.platform == "android" +) { + for (const perm of PRIVILEGED_PERMS_DESKTOP_ONLY) { + PRIVILEGED_PERMS.delete(perm); + } +} + +// Message included in warnings and errors related to privileged permissions and +// privileged manifest properties. Provides a link to the firefox-source-docs.mozilla.org +// section related to developing and sign Privileged Add-ons. +const PRIVILEGED_ADDONS_DEVDOCS_MESSAGE = + "See https://mzl.la/3NS9KJd for more details about how to develop a privileged add-on."; + +const INSTALL_AND_UPDATE_STARTUP_REASONS = new Set([ + "ADDON_INSTALL", + "ADDON_UPGRADE", + "ADDON_DOWNGRADE", +]); + +const PROTOCOL_HANDLER_OPEN_PERM_KEY = "open-protocol-handler"; +const PERMISSION_KEY_DELIMITER = "^"; + +// These are used for manipulating jar entry paths, which always use Unix +// separators (originally copied from `ospath_unix.jsm` as part of the "OS.Path +// to PathUtils" migration). + +/** + * Return the final part of the path. + * The final part of the path is everything after the last "/". + */ +function basename(path) { + return path.slice(path.lastIndexOf("/") + 1); +} + +/** + * Return the directory part of the path. + * The directory part of the path is everything before the last + * "/". If the last few characters of this part are also "/", + * they are ignored. + * + * If the path contains no directory, return ".". + */ +function dirname(path) { + let index = path.lastIndexOf("/"); + if (index == -1) { + return "."; + } + while (index >= 0 && path[index] == "/") { + --index; + } + return path.slice(0, index + 1); +} + +// Returns true if the extension is owned by Mozilla (is either privileged, +// using one of the @mozilla.com/@mozilla.org protected addon id suffixes). +// +// This method throws if the extension's startupReason is not one of the +// expected ones (either ADDON_INSTALL, ADDON_UPGRADE or ADDON_DOWNGRADE). +// +// TODO(Bug 1835787): Consider to remove the restriction based on the +// startupReason now that the recommendationState property is always +// included in the addonData with any of the startupReason. +function isMozillaExtension(extension) { + const { addonData, id, isPrivileged, startupReason } = extension; + + if (!INSTALL_AND_UPDATE_STARTUP_REASONS.has(startupReason)) { + throw new Error( + `isMozillaExtension called with unexpected startupReason: ${startupReason}` + ); + } + + if (isPrivileged) { + return true; + } + + if (id.endsWith("@mozilla.com") || id.endsWith("@mozilla.org")) { + return true; + } + + // This check is a subset of what is being checked in AddonWrapper's + // recommendationStates (states expire dates for line extensions are + // not considered important in determining that the extension is + // provided by mozilla, and so they are omitted here on purpose). + const isMozillaLineExtension = + addonData.recommendationState?.states?.includes("line"); + const isSigned = + addonData.signedState > lazy.AddonManager.SIGNEDSTATE_MISSING; + + return isSigned && isMozillaLineExtension; +} + +/** + * Classify an individual permission from a webextension manifest + * as a host/origin permission, an api permission, or a regular permission. + * + * @param {string} perm The permission string to classify + * @param {boolean} restrictSchemes + * @param {boolean} isPrivileged whether or not the webextension is privileged + * + * @returns {object} + * An object with exactly one of the following properties: + * "origin" to indicate this is a host/origin permission. + * "api" to indicate this is an api permission + * (as used for webextensions experiments). + * "permission" to indicate this is a regular permission. + * "invalid" to indicate that the given permission cannot be used. + */ +function classifyPermission(perm, restrictSchemes, isPrivileged) { + let match = /^(\w+)(?:\.(\w+)(?:\.\w+)*)?$/.exec(perm); + if (!match) { + try { + let { pattern } = new MatchPattern(perm, { + restrictSchemes, + ignorePath: true, + }); + return { origin: pattern }; + } catch (e) { + return { invalid: perm }; + } + } else if (match[1] == "experiments" && match[2]) { + return { api: match[2] }; + } else if (!isPrivileged && PRIVILEGED_PERMS.has(match[1])) { + return { invalid: perm, privileged: true }; + } else if (perm.startsWith("declarativeNetRequest") && !lazy.dnrEnabled) { + return { invalid: perm }; + } + return { permission: perm }; +} + +const LOGGER_ID_BASE = "addons.webextension."; +const UUID_MAP_PREF = "extensions.webextensions.uuids"; +const LEAVE_STORAGE_PREF = "extensions.webextensions.keepStorageOnUninstall"; +const LEAVE_UUID_PREF = "extensions.webextensions.keepUuidOnUninstall"; + +const COMMENT_REGEXP = new RegExp( + String.raw` + ^ + ( + (?: + [^"\n] | + " (?:[^"\\\n] | \\.)* " + )*? + ) + + //.* + `.replace(/\s+/g, ""), + "gm" +); + +// All moz-extension URIs use a machine-specific UUID rather than the +// extension's own ID in the host component. This makes it more +// difficult for web pages to detect whether a user has a given add-on +// installed (by trying to load a moz-extension URI referring to a +// web_accessible_resource from the extension). UUIDMap.get() +// returns the UUID for a given add-on ID. +var UUIDMap = { + _read() { + let pref = Services.prefs.getStringPref(UUID_MAP_PREF, "{}"); + try { + return JSON.parse(pref); + } catch (e) { + Cu.reportError(`Error parsing ${UUID_MAP_PREF}.`); + return {}; + } + }, + + _write(map) { + Services.prefs.setStringPref(UUID_MAP_PREF, JSON.stringify(map)); + }, + + get(id, create = true) { + let map = this._read(); + + if (id in map) { + return map[id]; + } + + let uuid = null; + if (create) { + uuid = Services.uuid.generateUUID().number; + uuid = uuid.slice(1, -1); // Strip { and } off the UUID. + + map[id] = uuid; + this._write(map); + } + return uuid; + }, + + remove(id) { + let map = this._read(); + delete map[id]; + this._write(map); + }, +}; + +function clearCacheForExtensionPrincipal(principal, clearAll = false) { + if (!principal.schemeIs("moz-extension")) { + return Promise.reject(new Error("Unexpected non extension principal")); + } + + // TODO(Bug 1750053): replace the two specific flags with a "clear all caches one" + // (along with covering the other kind of cached data with tests). + const clearDataFlags = clearAll + ? Ci.nsIClearDataService.CLEAR_ALL_CACHES + : Ci.nsIClearDataService.CLEAR_IMAGE_CACHE | + Ci.nsIClearDataService.CLEAR_CSS_CACHE; + + return new Promise(resolve => + Services.clearData.deleteDataFromPrincipal( + principal, + false, + clearDataFlags, + () => resolve() + ) + ); +} + +/** + * Observer AddonManager events and translate them into extension events, + * as well as handle any last cleanup after uninstalling an extension. + */ +var ExtensionAddonObserver = { + initialized: false, + + init() { + if (!this.initialized) { + lazy.AddonManager.addAddonListener(this); + this.initialized = true; + } + }, + + // AddonTestUtils will call this as necessary. + uninit() { + if (this.initialized) { + lazy.AddonManager.removeAddonListener(this); + this.initialized = false; + } + }, + + onEnabling(addon) { + if (addon.type !== "extension") { + return; + } + Management._callHandlers([addon.id], "enabling", "onEnabling"); + }, + + onDisabled(addon) { + if (addon.type !== "extension") { + return; + } + if (Services.appinfo.inSafeMode) { + // Ensure ExtensionPreferencesManager updates its data and + // modules can run any disable logic they need to. We only + // handle safeMode here because there is a bunch of additional + // logic that happens in Extension.shutdown when running in + // normal mode. + Management._callHandlers([addon.id], "disable", "onDisable"); + } + }, + + onUninstalling(addon) { + let extension = GlobalManager.extensionMap.get(addon.id); + if (extension) { + // Let any other interested listeners respond + // (e.g., display the uninstall URL) + Management.emit("uninstalling", extension); + } + }, + + onUninstalled(addon) { + // Cleanup anything that is used by non-extension addon types + // since only extensions have uuid's. + lazy.ExtensionPermissions.removeAll(addon.id); + + lazy.QuarantinedDomains.clearUserPref(addon.id); + + let uuid = UUIDMap.get(addon.id, false); + if (!uuid) { + return; + } + + let baseURI = Services.io.newURI(`moz-extension://${uuid}/`); + let principal = Services.scriptSecurityManager.createContentPrincipal( + baseURI, + {} + ); + + // Clear all cached resources (e.g. CSS and images); + lazy.AsyncShutdown.profileChangeTeardown.addBlocker( + `Clear cache for ${addon.id}`, + clearCacheForExtensionPrincipal(principal, /* clearAll */ true) + ); + + // Clear all the registered service workers for the extension + // principal (the one that may have been registered through the + // manifest.json file and the ones that may have been registered + // from an extension page through the service worker API). + // + // Any stored data would be cleared below (if the pref + // "extensions.webextensions.keepStorageOnUninstall has not been + // explicitly set to true, which is usually only done in + // tests and by some extensions developers for testing purpose). + // + // TODO: ServiceWorkerCleanUp may go away once Bug 1183245 + // is fixed, and so this may actually go away, replaced by + // marking the registration as disabled or to be removed on + // shutdown (where we do know if the extension is shutting + // down because is being uninstalled) and then cleared from + // the persisted serviceworker registration on the next + // startup. + lazy.AsyncShutdown.profileChangeTeardown.addBlocker( + `Clear ServiceWorkers for ${addon.id}`, + lazy.ServiceWorkerCleanUp.removeFromPrincipal(principal) + ); + + // Clear the persisted dynamic content scripts created with the scripting + // API (if any). + lazy.AsyncShutdown.profileChangeTeardown.addBlocker( + `Clear scripting store for ${addon.id}`, + lazy.ExtensionScriptingStore.clearOnUninstall(addon.id) + ); + + // Clear the DNR API's rules data persisted on disk (if any). + lazy.AsyncShutdown.profileChangeTeardown.addBlocker( + `Clear declarativeNetRequest store for ${addon.id}`, + lazy.ExtensionDNRStore.clearOnUninstall(uuid) + ); + + if (!Services.prefs.getBoolPref(LEAVE_STORAGE_PREF, false)) { + // Clear browser.storage.local backends. + lazy.AsyncShutdown.profileChangeTeardown.addBlocker( + `Clear Extension Storage ${addon.id} (File Backend)`, + lazy.ExtensionStorage.clear(addon.id, { shouldNotifyListeners: false }) + ); + + // Clear browser.storage.sync rust-based backend. + // (storage.sync clearOnUninstall will resolve and log an error on the + // browser console in case of unexpected failures). + if (!lazy.storageSyncOldKintoBackend) { + lazy.AsyncShutdown.profileChangeTeardown.addBlocker( + `Clear Extension StorageSync ${addon.id}`, + lazy.extensionStorageSync.clearOnUninstall(addon.id) + ); + } + + // Clear any IndexedDB and Cache API storage created by the extension. + // If LSNG is enabled, this also clears localStorage. + Services.qms.clearStoragesForPrincipal(principal); + + // Clear any storage.local data stored in the IDBBackend. + let storagePrincipal = + Services.scriptSecurityManager.createContentPrincipal(baseURI, { + userContextId: WEBEXT_STORAGE_USER_CONTEXT_ID, + }); + Services.qms.clearStoragesForPrincipal(storagePrincipal); + + lazy.ExtensionStorageIDB.clearMigratedExtensionPref(addon.id); + + // If LSNG is not enabled, we need to clear localStorage explicitly using + // the old API. + if (!Services.domStorageManager.nextGenLocalStorageEnabled) { + // Clear localStorage created by the extension + let storage = Services.domStorageManager.getStorage( + null, + principal, + principal + ); + if (storage) { + storage.clear(); + } + } + + // Remove any permissions related to the unlimitedStorage permission + // if we are also removing all the data stored by the extension. + Services.perms.removeFromPrincipal( + principal, + "WebExtensions-unlimitedStorage" + ); + Services.perms.removeFromPrincipal(principal, "persistent-storage"); + } + + // Clear any protocol handler permissions granted to this add-on. + let permissions = Services.perms.getAllWithTypePrefix( + PROTOCOL_HANDLER_OPEN_PERM_KEY + PERMISSION_KEY_DELIMITER + ); + for (let perm of permissions) { + if (perm.principal.equalsURI(baseURI)) { + Services.perms.removePermission(perm); + } + } + + if (!Services.prefs.getBoolPref(LEAVE_UUID_PREF, false)) { + // Clear the entry in the UUID map + UUIDMap.remove(addon.id); + } + }, + + onPropertyChanged(addon, properties) { + let extension = GlobalManager.extensionMap.get(addon.id); + if (extension && properties.includes("quarantineIgnoredByUser")) { + extension.ignoreQuarantine = addon.quarantineIgnoredByUser; + extension.policy.ignoreQuarantine = addon.quarantineIgnoredByUser; + + extension.setSharedData("", extension.serialize()); + Services.ppmm.sharedData.flush(); + + extension.emit("update-ignore-quarantine"); + extension.broadcast("Extension:UpdateIgnoreQuarantine", { + id: extension.id, + ignoreQuarantine: addon.quarantineIgnoredByUser, + }); + } + }, +}; + +ExtensionAddonObserver.init(); + +/** + * Observer ExtensionProcess crashes and notify all the extensions + * using a Management event named "extension-process-crash". + */ +export var ExtensionProcessCrashObserver = { + initialized: false, + + // For Android apps we initially consider the app as always starting + // in the background, then we expect to be setting it to foreground + // when GeckoView LifecycleListener onResume method is called on the + // Android app first startup. After the application has got on the + // foreground for the first time then onPause/onResumed LifecycleListener + // are called, the application-foreground/-background topics will be + // notified to Gecko and this flag will be updated accordingly. + _appInForeground: AppConstants.platform !== "android", + _isAndroid: AppConstants.platform === "android", + _processSpawningDisabled: false, + + // Technically there is at most one child extension process, + // but we may need to adjust this assumption to account for more + // than one if that ever changes in the future. + currentProcessChildID: undefined, + lastCrashedProcessChildID: undefined, + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + + // Collect the timestamps of the crashes happened over the last + // `processCrashTimeframe` milliseconds. + lastCrashTimestamps: [], + + init() { + if (!this.initialized) { + Services.obs.addObserver(this, "ipc:content-created"); + Services.obs.addObserver(this, "process-type-set"); + Services.obs.addObserver(this, "ipc:content-shutdown"); + this.logger = lazy.Log.repository.getLogger( + "addons.process-crash-observer" + ); + if (this._isAndroid) { + Services.obs.addObserver(this, "geckoview-initial-foreground"); + Services.obs.addObserver(this, "application-foreground"); + Services.obs.addObserver(this, "application-background"); + } + this.initialized = true; + } + }, + + uninit() { + if (this.initialized) { + try { + Services.obs.removeObserver(this, "ipc:content-created"); + Services.obs.removeObserver(this, "process-type-set"); + Services.obs.removeObserver(this, "ipc:content-shutdown"); + if (this._isAndroid) { + Services.obs.removeObserver(this, "geckoview-initial-foreground"); + Services.obs.removeObserver(this, "application-foreground"); + Services.obs.removeObserver(this, "application-background"); + } + } catch (err) { + // Removing the observer may fail if they are not registered anymore, + // this shouldn't happen in practice, but let's still log the error + // in case it does. + Cu.reportError(err); + } + this.initialized = false; + } + }, + + observe(subject, topic, data) { + let childID = data; + switch (topic) { + case "geckoview-initial-foreground": + this._appInForeground = true; + this.logger.debug( + `Detected Android application moved in the foreground (geckoview-initial-foreground)` + ); + break; + case "application-foreground": + // Intentional fall-through + case "application-background": + this._appInForeground = topic === "application-foreground"; + this.logger.debug( + `Detected Android application moved in the ${ + this._appInForeground ? "foreground" : "background" + }` + ); + if (this._appInForeground) { + Management.emit("application-foreground", { + appInForeground: this._appInForeground, + childID: this.currentProcessChildID, + processSpawningDisabled: this.processSpawningDisabled, + }); + } + break; + case "process-type-set": + // Intentional fall-through + case "ipc:content-created": { + let pp = subject.QueryInterface(Ci.nsIDOMProcessParent); + if (pp.remoteType === "extension") { + this.currentProcessChildID = childID; + Glean.extensions.processEvent[ + this.appInForeground ? "created_fg" : "created_bg" + ].add(1); + } + break; + } + case "ipc:content-shutdown": { + if (Services.startup.shuttingDown) { + // The application is shutting down, don't bother + // signaling process crashes anymore. + return; + } + if (this.currentProcessChildID !== childID) { + // Ignore non-extension child process shutdowns. + return; + } + + // At this point we are sure that the current extension + // process is gone, and so even if the process did shutdown + // cleanly instead of crashing, we can clear the property + // that keeps track of the current extension process childID. + this.currentProcessChildID = undefined; + + subject.QueryInterface(Ci.nsIPropertyBag2); + if (!subject.get("abnormal")) { + // Ignore non-abnormal child process shutdowns. + return; + } + + this.lastCrashedProcessChildID = childID; + + const now = Cu.now(); + // Filter crash timestamps older than processCrashTimeframe. + this.lastCrashTimestamps = this.lastCrashTimestamps.filter( + timestamp => now - timestamp < lazy.processCrashTimeframe + ); + // Push the new timeframe. + this.lastCrashTimestamps.push(now); + // Set the flag that disable process spawning when we exceed the + // `processCrashThreshold`. + this._processSpawningDisabled = + this.lastCrashTimestamps.length > lazy.processCrashThreshold; + + this.logger.debug( + `Extension process crashed ${this.lastCrashTimestamps.length} times over the last ${lazy.processCrashTimeframe}ms` + ); + + const { appInForeground } = this; + + if (this.processSpawningDisabled) { + if (appInForeground) { + Glean.extensions.processEvent.crashed_over_threshold_fg.add(1); + } else { + Glean.extensions.processEvent.crashed_over_threshold_bg.add(1); + } + this.logger.warn( + `Extension process respawning disabled because it crashed too often in the last ${lazy.processCrashTimeframe}ms (${this.lastCrashTimestamps.length} > ${lazy.processCrashThreshold}).` + ); + } + + Glean.extensions.processEvent[ + appInForeground ? "crashed_fg" : "crashed_bg" + ].add(1); + Management.emit("extension-process-crash", { + childID, + processSpawningDisabled: this.processSpawningDisabled, + appInForeground, + }); + break; + } + } + }, + + enableProcessSpawning() { + const crashCounter = this.lastCrashTimestamps.length; + this.lastCrashTimestamps = []; + this.logger.debug(`reset crash counter (was ${crashCounter})`); + this._processSpawningDisabled = false; + Management.emit("extension-enable-process-spawning"); + }, + + get appInForeground() { + // Only account for application in the background for + // android builds. + return this._isAndroid ? this._appInForeground : true; + }, + + get processSpawningDisabled() { + return this._processSpawningDisabled; + }, +}; + +ExtensionProcessCrashObserver.init(); + +const manifestTypes = new Map([ + ["theme", "manifest.ThemeManifest"], + ["locale", "manifest.WebExtensionLangpackManifest"], + ["dictionary", "manifest.WebExtensionDictionaryManifest"], + ["extension", "manifest.WebExtensionManifest"], + // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed. + ["sitepermission-deprecated", "manifest.WebExtensionSitePermissionsManifest"], +]); + +/** + * Represents the data contained in an extension, contained either + * in a directory or a zip file, which may or may not be installed. + * This class implements the functionality of the Extension class, + * primarily related to manifest parsing and localization, which is + * useful prior to extension installation or initialization. + * + * No functionality of this class is guaranteed to work before + * `loadManifest` has been called, and completed. + */ +export class ExtensionData { + /** + * Note: These fields are only available and meant to be used on Extension + * instances, declared here because methods from this class reference them. + */ + /** @type {object} TODO: move to the Extension class, bug 1871094. */ + addonData; + /** @type {nsIURI} */ + baseURI; + /** @type {nsIPrincipal} */ + principal; + /** @type {boolean} */ + temporarilyInstalled; + + constructor(rootURI, isPrivileged = false) { + this.rootURI = rootURI; + this.resourceURL = rootURI.spec; + this.isPrivileged = isPrivileged; + + this.manifest = null; + this.type = null; + this.id = null; + this.uuid = null; + this.localeData = null; + this.fluentL10n = null; + this._promiseLocales = null; + + this.apiNames = new Set(); + this.dependencies = new Set(); + this.permissions = new Set(); + + this.startupData = null; + + this.errors = []; + this.warnings = []; + this.eventPagesEnabled = lazy.eventPagesEnabled; + } + + /** + * A factory function that allows the construction of ExtensionData, with + * the isPrivileged flag computed asynchronously. + * + * @param {object} options + * @param {nsIURI} options.rootURI + * The URI pointing to the extension root. + * @param {function(type, id): boolean} options.checkPrivileged + * An (async) function that takes the addon type and addon ID and returns + * whether the given add-on is privileged. + * @param {boolean} options.temporarilyInstalled + * whether the given add-on is installed as temporary. + * @returns {Promise} + */ + static async constructAsync({ + rootURI, + checkPrivileged, + temporarilyInstalled, + }) { + let extension = new ExtensionData(rootURI); + // checkPrivileged depends on the extension type and id. + await extension.initializeAddonTypeAndID(); + let { type, id } = extension; + extension.isPrivileged = await checkPrivileged(type, id); + extension.temporarilyInstalled = temporarilyInstalled; + return extension; + } + + static getIsPrivileged({ signedState, builtIn, temporarilyInstalled }) { + return ( + signedState === lazy.AddonManager.SIGNEDSTATE_PRIVILEGED || + signedState === lazy.AddonManager.SIGNEDSTATE_SYSTEM || + builtIn || + (lazy.AddonSettings.EXPERIMENTS_ENABLED && temporarilyInstalled) + ); + } + + get builtinMessages() { + return null; + } + + get logger() { + let id = this.id || ""; + return lazy.Log.repository.getLogger(LOGGER_ID_BASE + id); + } + + /** + * Report an error about the extension's manifest file. + * + * @param {string} message The error message + */ + manifestError(message) { + this.packagingError(`Reading manifest: ${message}`); + } + + /** + * Report a warning about the extension's manifest file. + * + * @param {string} message The warning message + */ + manifestWarning(message) { + this.packagingWarning(`Reading manifest: ${message}`); + } + + // Report an error about the extension's general packaging. + packagingError(message) { + this.errors.push(message); + this.logError(message); + } + + packagingWarning(message) { + this.warnings.push(message); + this.logWarning(message); + } + + logWarning(message) { + this._logMessage(message, "warn"); + } + + logError(message) { + this._logMessage(message, "error"); + } + + _logMessage(message, severity) { + this.logger[severity](`Loading extension '${this.id}': ${message}`); + } + + ensureNoErrors() { + if (this.errors.length) { + // startup() repeatedly checks whether there are errors after parsing the + // extension/manifest before proceeding with starting up. + throw new Error(this.errors.join("\n")); + } + } + + /** + * Returns the moz-extension: URL for the given path within this + * extension. + * + * Must not be called unless either the `id` or `uuid` property has + * already been set. + * + * @param {string} path The path portion of the URL. + * @returns {string} + */ + getURL(path = "") { + if (!(this.id || this.uuid)) { + throw new Error( + "getURL may not be called before an `id` or `uuid` has been set" + ); + } + if (!this.uuid) { + this.uuid = UUIDMap.get(this.id); + } + return `moz-extension://${this.uuid}/${path}`; + } + + /** + * Discovers the file names within a directory or JAR file. + * + * @param {string} path + * The path to the directory or jar file to look at. + * @param {boolean} [directoriesOnly] + * If true, this will return only the directories present within the directory. + * @returns {Promise} + * An array of names of files/directories (only the name, not the path). + */ + async _readDirectory(path, directoriesOnly = false) { + if (this.rootURI instanceof Ci.nsIFileURL) { + let uri = Services.io.newURI("./" + path, null, this.rootURI); + let fullPath = uri.QueryInterface(Ci.nsIFileURL).file.path; + + let results = []; + try { + let children = await IOUtils.getChildren(fullPath); + for (let child of children) { + if ( + !directoriesOnly || + (await IOUtils.stat(child)).type == "directory" + ) { + results.push(PathUtils.filename(child)); + } + } + } catch (ex) { + // Fall-through, return what we have. + } + return results; + } + + let uri = this.rootURI.QueryInterface(Ci.nsIJARURI); + + // Append the sub-directory path to the base JAR URI and normalize the + // result. + let entry = `${uri.JAREntry}/${path}/` + .replace(/\/\/+/g, "/") + .replace(/^\//, ""); + uri = Services.io.newURI(`jar:${uri.JARFile.spec}!/${entry}`); + + let results = []; + for (let name of lazy.aomStartup.enumerateJARSubtree(uri)) { + if (!name.startsWith(entry)) { + throw new Error("Unexpected ZipReader entry"); + } + + // The enumerator returns the full path of all entries. + // Trim off the leading path, and filter out entries from + // subdirectories. + name = name.slice(entry.length); + if ( + name && + !/\/./.test(name) && + (!directoriesOnly || name.endsWith("/")) + ) { + results.push(name.replace("/", "")); + } + } + + return results; + } + + readJSON(path) { + return new Promise((resolve, reject) => { + let uri = this.rootURI.resolve(`./${path}`); + + lazy.NetUtil.asyncFetch( + { uri, loadUsingSystemPrincipal: true }, + (inputStream, status) => { + if (!Components.isSuccessCode(status)) { + // Convert status code to a string + let e = Components.Exception("", status); + reject(new Error(`Error while loading '${uri}' (${e.name})`)); + return; + } + try { + let text = lazy.NetUtil.readInputStreamToString( + inputStream, + inputStream.available(), + { charset: "utf-8" } + ); + + text = text.replace(COMMENT_REGEXP, "$1"); + + resolve(JSON.parse(text)); + } catch (e) { + reject(e); + } + } + ); + }); + } + + get restrictSchemes() { + return !(this.isPrivileged && this.hasPermission("mozillaAddons")); + } + + /** + * Given an array of host and permissions, generate a structured permissions object + * that contains seperate host origins and permissions arrays. + * + * @param {Array} permissionsArray + * @param {Array} [hostPermissions] + * @returns {object} permissions object + */ + permissionsObject(permissionsArray = [], hostPermissions = []) { + let permissions = new Set(); + let origins = new Set(); + let { restrictSchemes, isPrivileged } = this; + + for (let perm of permissionsArray.concat(hostPermissions)) { + let type = classifyPermission(perm, restrictSchemes, isPrivileged); + if (type.origin) { + origins.add(perm); + } else if (type.permission) { + permissions.add(perm); + } + } + + return { + permissions, + origins, + }; + } + + /** + * Returns an object representing any capabilities that the extension + * has access to based on fixed properties in the manifest. The result + * includes the contents of the "permissions" property as well as other + * capabilities that are derived from manifest fields that users should + * be informed of (e.g., origins where content scripts are injected). + */ + get manifestPermissions() { + if (this.type !== "extension") { + return null; + } + + let { permissions } = this.permissionsObject(this.manifest.permissions); + + if ( + this.manifest.devtools_page && + !this.manifest.optional_permissions.includes("devtools") + ) { + permissions.add("devtools"); + } + + return { + permissions: Array.from(permissions), + origins: this.originControls ? [] : this.getManifestOrigins(), + }; + } + + /** + * @returns {string[]} all origins that are referenced in manifest via + * permissions, host_permissions, or content_scripts keys. + */ + getManifestOrigins() { + if (this.type !== "extension") { + return null; + } + + let { origins } = this.permissionsObject( + this.manifest.permissions, + this.manifest.host_permissions + ); + + for (let entry of this.manifest.content_scripts || []) { + for (let origin of entry.matches) { + origins.add(origin); + } + } + + return Array.from(origins); + } + + /** + * Returns optional permissions from the manifest, including host permissions + * if originControls is true. + */ + get manifestOptionalPermissions() { + if (this.type !== "extension") { + return null; + } + + let { permissions, origins } = this.permissionsObject( + this.manifest.optional_permissions + ); + if (this.originControls) { + for (let origin of this.getManifestOrigins()) { + origins.add(origin); + } + } + + return { + permissions: Array.from(permissions), + origins: Array.from(origins), + }; + } + + /** + * Returns an object representing all capabilities this extension has + * access to, including fixed ones from the manifest as well as dynamically + * granted permissions. + */ + get activePermissions() { + if (this.type !== "extension") { + return null; + } + + let result = { + origins: this.allowedOrigins.patterns + .map(matcher => matcher.pattern) + // moz-extension://id/* is always added to allowedOrigins, but it + // is not a valid host permission in the API. So, remove it. + .filter(pattern => !pattern.startsWith("moz-extension:")), + apis: [...this.apiNames], + }; + + const EXP_PATTERN = /^experiments\.\w+/; + result.permissions = [...this.permissions].filter( + p => !result.origins.includes(p) && !EXP_PATTERN.test(p) + ); + return result; + } + + // Returns whether the front end should prompt for this permission + static async shouldPromptFor(permission) { + return !(await lazy.NO_PROMPT_PERMISSIONS).has(permission); + } + + // Compute the difference between two sets of permissions, suitable + // for presenting to the user. + static comparePermissions(oldPermissions, newPermissions) { + let oldMatcher = new MatchPatternSet(oldPermissions.origins, { + restrictSchemes: false, + }); + return { + // formatPermissionStrings ignores any scheme, so only look at the domain. + origins: newPermissions.origins.filter( + perm => + !oldMatcher.subsumesDomain( + new MatchPattern(perm, { restrictSchemes: false }) + ) + ), + permissions: newPermissions.permissions.filter( + perm => !oldPermissions.permissions.includes(perm) + ), + }; + } + + // Return those permissions in oldPermissions that also exist in newPermissions. + static intersectPermissions(oldPermissions, newPermissions) { + let matcher = new MatchPatternSet(newPermissions.origins, { + restrictSchemes: false, + }); + + return { + origins: oldPermissions.origins.filter(perm => + matcher.subsumesDomain( + new MatchPattern(perm, { restrictSchemes: false }) + ) + ), + permissions: oldPermissions.permissions.filter(perm => + newPermissions.permissions.includes(perm) + ), + }; + } + + /** + * When updating the addon, find and migrate permissions that have moved from required + * to optional. This also handles any updates required for permission removal. + * + * @param {string} id The id of the addon being updated + * @param {object} oldPermissions + * @param {object} oldOptionalPermissions + * @param {object} newPermissions + * @param {object} newOptionalPermissions + */ + static async migratePermissions( + id, + oldPermissions, + oldOptionalPermissions, + newPermissions, + newOptionalPermissions + ) { + let migrated = ExtensionData.intersectPermissions( + oldPermissions, + newOptionalPermissions + ); + // If a permission is optional in this version and was mandatory in the previous + // version, it was already accepted by the user at install time so add it to the + // list of granted optional permissions now. + await lazy.ExtensionPermissions.add(id, migrated); + + // Now we need to update ExtensionPreferencesManager, removing any settings + // for old permissions that no longer exist. + let permSet = new Set( + newPermissions.permissions.concat(newOptionalPermissions.permissions) + ); + let oldPerms = oldPermissions.permissions.concat( + oldOptionalPermissions.permissions + ); + + let removed = oldPerms.filter(x => !permSet.has(x)); + // Force the removal here to ensure the settings are removed prior + // to startup. This will remove both required or optional permissions, + // whereas the call from within ExtensionPermissions would only result + // in a removal for optional permissions that were removed. + await lazy.ExtensionPreferencesManager.removeSettingsForPermissions( + id, + removed + ); + + // Remove any optional permissions that have been removed from the manifest. + await lazy.ExtensionPermissions.remove(id, { + permissions: removed, + origins: [], + }); + } + + canUseAPIExperiment() { + return ( + this.type == "extension" && + (this.isPrivileged || + // TODO(Bug 1771341): Allowing the "experiment_apis" property when only + // AddonSettings.EXPERIMENTS_ENABLED is true is currently needed to allow, + // while running under automation, the test harness extensions (like mochikit + // and specialpowers) to use that privileged manifest property. + lazy.AddonSettings.EXPERIMENTS_ENABLED) + ); + } + + canUseThemeExperiment() { + return ( + ["extension", "theme"].includes(this.type) && + (this.isPrivileged || + // "theme_experiment" MDN docs are currently explicitly mentioning this is expected + // to be allowed also for non-signed extensions installed non-temporarily on builds + // where the signature checks can be disabled). + // + // NOTE: be careful to don't regress "theme_experiment" (see Bug 1773076) while changing + // AddonSettings.EXPERIMENTS_ENABLED (e.g. as part of fixing Bug 1771341). + lazy.AddonSettings.EXPERIMENTS_ENABLED) + ); + } + + get manifestVersion() { + return this.manifest.manifest_version; + } + + get persistentBackground() { + let { manifest } = this; + if ( + !manifest.background || + (manifest.background.service_worker && + WebExtensionPolicy.backgroundServiceWorkerEnabled) || + this.manifestVersion > 2 + ) { + return false; + } + // V2 addons can only use event pages if the pref is also flipped and + // persistent is explicilty set to false. + return !this.eventPagesEnabled || manifest.background.persistent; + } + + /** + * backgroundState can be starting, running, suspending or stopped. + * It is undefined if the extension has no background page. + * See ext-backgroundPage.js for more details. + * + * @param {string} state starting, running, suspending or stopped + */ + set backgroundState(state) { + this._backgroundState = state; + } + + get backgroundState() { + return this._backgroundState; + } + + async getExtensionVersionWithoutValidation() { + return (await this.readJSON("manifest.json")).version; + } + + /** + * Load a locale and return a localized manifest. The extension must + * be initialized, and manifest parsed prior to calling. + * + * @param {string} locale to load, if necessary. + * @returns {Promise} normalized manifest. + */ + async getLocalizedManifest(locale) { + if (!this.type || !this.localeData) { + throw new Error("The extension has not been initialized."); + } + // Upon update or reinstall, the Extension.manifest may be read from + // StartupCache.manifest, however rawManifest is *not*. We need the + // raw manifest in order to get a localized manifest. + if (!this.rawManifest) { + this.rawManifest = await this.readJSON("manifest.json"); + } + + if (!this.localeData.has(locale)) { + // Locales are not avialable until some additional + // initialization is done. We could just call initAllLocales, + // but that is heavy handed, especially when we likely only + // need one out of 20. + let locales = await this.promiseLocales(); + if (locales.get(locale)) { + await this.initLocale(locale); + } + if (!this.localeData.has(locale)) { + throw new Error(`The extension does not contain the locale ${locale}`); + } + } + let normalized = await this._getNormalizedManifest(locale); + if (normalized.error) { + throw new Error(normalized.error); + } + return normalized.value; + } + + async _getNormalizedManifest(locale) { + let manifestType = manifestTypes.get(this.type); + + let context = { + url: this.baseURI && this.baseURI.spec, + principal: this.principal, + logError: error => { + this.manifestWarning(error); + }, + preprocessors: {}, + manifestVersion: this.manifestVersion, + }; + + if (this.fluentL10n || this.localeData) { + context.preprocessors.localize = (value, context) => + this.localize(value, locale); + } + + return lazy.Schemas.normalize(this.rawManifest, manifestType, context); + } + + #parseBrowserStyleInManifest(manifest, manifestKey, defaultValueInMV2) { + const obj = manifest[manifestKey]; + if (!obj) { + return; + } + const browserStyleIsVoid = obj.browser_style == null; + obj.browser_style ??= defaultValueInMV2; + if (this.manifestVersion < 3 || !obj.browser_style) { + // MV2 (true or false), or MV3 (false set explicitly or default false). + // No changes in observed behavior, return now to avoid logspam. + return; + } + // Now there are two cases (MV3 only): + // - browser_style was not specified, but defaults to true. + // - browser_style was set to true by the extension. + // + // These will eventually be deprecated. For the deprecation plan, see + // https://bugzilla.mozilla.org/show_bug.cgi?id=1827910#c1 + let warning; + if (!lazy.browserStyleMV3supported) { + obj.browser_style = false; + if (browserStyleIsVoid && !lazy.browserStyleMV3sameAsMV2) { + // defaultValueInMV2 is true, but there was no intent to use these + // defaults. Don't warn. + return; + } + warning = `"browser_style:true" is no longer supported in Manifest Version 3.`; + } else { + warning = `"browser_style:true" has been deprecated in Manifest Version 3 and will be unsupported in the near future.`; + } + if (browserStyleIsVoid) { + warning += ` While "${manifestKey}.browser_style" was not explicitly specified in manifest.json, its default value was true.`; + if (!lazy.browserStyleMV3sameAsMV2) { + obj.browser_style = false; + warning += ` The default value of "${manifestKey}.browser_style" has changed from true to false in Manifest Version 3.`; + } else { + warning += ` Its default will change to false in Manifest Version 3 starting from Firefox 115.`; + } + } + + this.manifestWarning( + `Warning processing ${manifestKey}.browser_style: ${warning}` + ); + } + + async initializeAddonTypeAndID() { + if (this.type) { + // Already initialized. + return; + } + this.rawManifest = await this.readJSON("manifest.json"); + let manifest = this.rawManifest; + + if (manifest.theme) { + this.type = "theme"; + } else if (manifest.langpack_id) { + this.type = "locale"; + } else if (manifest.dictionaries) { + this.type = "dictionary"; + } else if (manifest.site_permissions) { + // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed. + this.type = "sitepermission-deprecated"; + } else { + this.type = "extension"; + } + + if (!this.id) { + let bss = + manifest.browser_specific_settings?.gecko || + manifest.applications?.gecko; + let id = bss?.id; + // This is a basic type check. + // When parseManifest is called, the ID is validated more thoroughly + // because the id is defined to be an ExtensionID type in + // toolkit/components/extensions/schemas/manifest.json + if (typeof id == "string") { + this.id = id; + } + } + } + + // eslint-disable-next-line complexity + async parseManifest() { + await Promise.all([this.initializeAddonTypeAndID(), Management.lazyInit()]); + + let manifest = this.rawManifest; + this.manifest = manifest; + + if (manifest.default_locale) { + await this.initLocale(); + } + + if (manifest.l10n_resources) { + if (this.isPrivileged) { + // TODO (Bug 1733466): For historical reasons fluent isn't being used to + // localize manifest properties read from the add-on manager (e.g., author, + // homepage, etc.), the changes introduced by Bug 1734987 does now ensure + // that isPrivileged will be set while parsing the manifest and so this + // can be now supported but requires some additional changes, being tracked + // by Bug 1733466. + if (this.constructor != ExtensionData) { + this.fluentL10n = new Localization(manifest.l10n_resources, true); + } + } else if (this.temporarilyInstalled) { + this.manifestError( + `Using 'l10n_resources' requires a privileged add-on. ` + + PRIVILEGED_ADDONS_DEVDOCS_MESSAGE + ); + } else { + // Warn but don't make this fatal. + this.manifestWarning( + "Ignoring l10n_resources in unprivileged extension" + ); + } + } + + let normalized = await this._getNormalizedManifest(); + if (normalized.error) { + this.manifestError(normalized.error); + return null; + } + + manifest = normalized.value; + + // `browser_specific_settings` is the recommended key to use in the + // manifest, and the only possible choice in MV3+. For MV2 extensions, we + // still allow `applications`, though. Because `applications` used to be + // the only key in the distant past, most internal code is written using + // applications. That's why we end up re-assigning `browser_specific_settings` + // to `applications` below. + // + // Also, when a MV3+ extension specifies `applications`, the key isn't + // recognized and therefore filtered out from the normalized manifest as + // part of the JSONSchema normalization. + if (manifest.browser_specific_settings?.gecko) { + if (manifest.applications) { + this.manifestWarning( + `"applications" property ignored and overridden by "browser_specific_settings"` + ); + } + manifest.applications = manifest.browser_specific_settings; + } + + // On Android, override the browser specific settings with those found in + // `bss.gecko_android`, if any. + // + // It is also worth noting that the `gecko_android` key in `applications` + // is marked as "unsupported" in the JSON schema. + if ( + AppConstants.platform == "android" && + manifest.browser_specific_settings?.gecko_android + ) { + const { strict_min_version, strict_max_version } = + manifest.browser_specific_settings.gecko_android; + + // When the manifest doesn't define `browser_specific_settings.gecko`, it + // is still possible to reach this block but `manifest.applications` + // won't be defined yet. + if (!manifest?.applications) { + manifest.applications = { + // All properties should be optional in `gecko` so we omit them here. + gecko: {}, + }; + } + + if (strict_min_version?.length) { + manifest.applications.gecko.strict_min_version = strict_min_version; + } + + if (strict_max_version?.length) { + manifest.applications.gecko.strict_max_version = strict_max_version; + } + } + + if ( + this.manifestVersion < 3 && + manifest.background && + !this.eventPagesEnabled && + !manifest.background.persistent + ) { + this.logWarning("Event pages are not currently supported."); + } + + if ( + this.isPrivileged && + manifest.hidden && + (manifest.action || manifest.browser_action || manifest.page_action) + ) { + this.manifestError( + "Cannot use browser and/or page actions in hidden add-ons" + ); + } + + if (manifest.options_ui) { + if (manifest.options_ui.open_in_tab) { + // browser_style:true has no effect when open_in_tab is true. + manifest.options_ui.browser_style = false; + } else { + this.#parseBrowserStyleInManifest(manifest, "options_ui", true); + } + } + if (this.manifestVersion < 3) { + this.#parseBrowserStyleInManifest(manifest, "browser_action", false); + } else { + this.#parseBrowserStyleInManifest(manifest, "action", false); + } + this.#parseBrowserStyleInManifest(manifest, "page_action", false); + if (AppConstants.MOZ_BUILD_APP === "browser") { + this.#parseBrowserStyleInManifest(manifest, "sidebar_action", true); + } + + let apiNames = new Set(); + let dependencies = new Set(); + let originPermissions = new Set(); + let permissions = new Set(); + let webAccessibleResources = []; + + let schemaPromises = new Map(); + + // Note: this.id and this.type were computed in initializeAddonTypeAndID. + // The format of `this.id` was confirmed to be a valid extensionID by the + // Schema validation as part of the _getNormalizedManifest() call. + let result = { + apiNames, + dependencies, + id: this.id, + manifest, + modules: null, + // Whether to treat all origin permissions (including content scripts) + // from the manifestas as optional, and enable users to control them. + originControls: this.manifestVersion >= 3, + originPermissions, + permissions, + schemaURLs: null, + type: this.type, + webAccessibleResources, + }; + + if (this.type === "extension") { + let { isPrivileged } = this; + let restrictSchemes = !( + isPrivileged && manifest.permissions.includes("mozillaAddons") + ); + + // Privileged and temporary extensions still get OriginControls, but + // can have host permissions automatically granted during install. + // For all other cases, ensure granted_host_permissions is false. + if (!isPrivileged && !this.temporarilyInstalled) { + manifest.granted_host_permissions = false; + } + + let host_permissions = manifest.host_permissions ?? []; + + for (let perm of manifest.permissions.concat(host_permissions)) { + if (perm === "geckoProfiler" && !isPrivileged) { + const acceptedExtensions = Services.prefs.getStringPref( + "extensions.geckoProfiler.acceptedExtensionIds", + "" + ); + if (!acceptedExtensions.split(",").includes(this.id)) { + this.manifestError( + "Only specific extensions are allowed to access the geckoProfiler." + ); + continue; + } + } + + let type = classifyPermission(perm, restrictSchemes, isPrivileged); + if (type.origin) { + perm = type.origin; + if (!result.originControls) { + originPermissions.add(perm); + } + } else if (type.api) { + apiNames.add(type.api); + } else if (type.invalid) { + // If EXPERIMENTS_ENABLED is not enabled prevent the install + // to ensure developer awareness. + if (this.temporarilyInstalled && type.privileged) { + this.manifestError( + `Using the privileged permission '${perm}' requires a privileged add-on. ` + + PRIVILEGED_ADDONS_DEVDOCS_MESSAGE + ); + continue; + } + this.manifestWarning(`Invalid extension permission: ${perm}`); + continue; + } + + // Unfortunately, we treat as an API permission as well. + if (!type.origin || (perm === "" && !result.originControls)) { + permissions.add(perm); + } + } + + if (this.id) { + // An extension always gets permission to its own url. + let matcher = new MatchPattern(this.getURL(), { ignorePath: true }); + originPermissions.add(matcher.pattern); + + // Apply optional permissions + let perms = await lazy.ExtensionPermissions.get(this.id); + for (let perm of perms.permissions) { + permissions.add(perm); + } + for (let origin of perms.origins) { + originPermissions.add(origin); + } + } + + for (let api of apiNames) { + dependencies.add(`${api}@experiments.addons.mozilla.org`); + } + + let moduleData = data => ({ + url: this.rootURI.resolve(data.script), + events: data.events, + paths: data.paths, + scopes: data.scopes, + }); + + let computeModuleInit = (scope, modules) => { + let manager = new ExtensionCommon.SchemaAPIManager(scope); + return manager.initModuleJSON([modules]); + }; + + result.contentScripts = []; + for (let options of manifest.content_scripts || []) { + result.contentScripts.push({ + allFrames: options.all_frames, + matchAboutBlank: options.match_about_blank, + frameID: options.frame_id, + runAt: options.run_at, + + matches: options.matches, + excludeMatches: options.exclude_matches || [], + includeGlobs: options.include_globs, + excludeGlobs: options.exclude_globs, + + jsPaths: options.js || [], + cssPaths: options.css || [], + }); + } + + if (manifest.experiment_apis) { + if (this.canUseAPIExperiment()) { + let parentModules = {}; + let childModules = {}; + + for (let [name, data] of Object.entries(manifest.experiment_apis)) { + let schema = this.getURL(data.schema); + + if (!schemaPromises.has(schema)) { + schemaPromises.set( + schema, + this.readJSON(data.schema).then(json => + lazy.Schemas.processSchema(json) + ) + ); + } + + if (data.parent) { + parentModules[name] = moduleData(data.parent); + } + + if (data.child) { + childModules[name] = moduleData(data.child); + } + } + + result.modules = { + child: computeModuleInit("addon_child", childModules), + parent: computeModuleInit("addon_parent", parentModules), + }; + } else if (this.temporarilyInstalled) { + // Hard error for un-privileged temporary installs using experimental apis. + this.manifestError( + `Using 'experiment_apis' requires a privileged add-on. ` + + PRIVILEGED_ADDONS_DEVDOCS_MESSAGE + ); + } else { + this.manifestWarning( + `Using experimental APIs requires a privileged add-on.` + ); + } + } + + // Normalize all patterns to contain a single leading / + if (manifest.web_accessible_resources) { + // Normalize into V3 objects + let wac = + this.manifestVersion >= 3 + ? manifest.web_accessible_resources + : [{ resources: manifest.web_accessible_resources }]; + webAccessibleResources.push( + ...wac.map(obj => { + obj.resources = obj.resources.map(path => + path.replace(/^\/*/, "/") + ); + return obj; + }) + ); + } + } else if (this.type == "locale") { + // Langpack startup is performance critical, so we want to compute as much + // as possible here to make startup not trigger async DB reads. + // We'll store the four items below in the startupData. + + // 1. Compute the chrome resources to be registered for this langpack. + const platform = AppConstants.platform; + const chromeEntries = []; + for (const [language, entry] of Object.entries(manifest.languages)) { + for (const [alias, path] of Object.entries( + entry.chrome_resources || {} + )) { + if (typeof path === "string") { + chromeEntries.push(["locale", alias, language, path]); + } else if (platform in path) { + // If the path is not a string, it's an object with path per + // platform where the keys are taken from AppConstants.platform + chromeEntries.push(["locale", alias, language, path[platform]]); + } + } + } + + // 2. Compute langpack ID. + const productCodeName = AppConstants.MOZ_BUILD_APP.replace("/", "-"); + + // The result path looks like this: + // Firefox - `langpack-pl-browser` + // Fennec - `langpack-pl-mobile-android` + const langpackId = `langpack-${manifest.langpack_id}-${productCodeName}`; + + // 3. Compute L10nRegistry sources for this langpack. + const l10nRegistrySources = {}; + + // Check if there's a root directory `/localization` in the langpack. + // If there is one, add it with the name `toolkit` as a FileSource. + const entries = await this._readDirectory("localization"); + if (entries.length) { + l10nRegistrySources.toolkit = ""; + } + + // Add any additional sources listed in the manifest + if (manifest.sources) { + for (const [sourceName, { base_path }] of Object.entries( + manifest.sources + )) { + l10nRegistrySources[sourceName] = base_path; + } + } + + // 4. Save the list of languages handled by this langpack. + const languages = Object.keys(manifest.languages); + + this.startupData = { + chromeEntries, + langpackId, + l10nRegistrySources, + languages, + }; + } else if (this.type == "dictionary") { + let dictionaries = {}; + for (let [lang, path] of Object.entries(manifest.dictionaries)) { + path = path.replace(/^\/+/, ""); + + let dir = dirname(path); + if (dir === ".") { + dir = ""; + } + let leafName = basename(path); + let affixPath = leafName.slice(0, -3) + "aff"; + + let entries = await this._readDirectory(dir); + if (!entries.includes(leafName)) { + this.manifestError( + `Invalid dictionary path specified for '${lang}': ${path}` + ); + } + if (!entries.includes(affixPath)) { + this.manifestError( + `Invalid dictionary path specified for '${lang}': Missing affix file: ${path}` + ); + } + + dictionaries[lang] = path; + } + + this.startupData = { dictionaries }; + } + + if (schemaPromises.size) { + let schemas = new Map(); + for (let [url, promise] of schemaPromises) { + schemas.set(url, await promise); + } + result.schemaURLs = schemas; + } + + return result; + } + + // Reads the extension's |manifest.json| file, and stores its + // parsed contents in |this.manifest|. + async loadManifest() { + let [manifestData] = await Promise.all([ + this.parseManifest(), + Management.lazyInit(), + ]); + + if (!manifestData) { + return; + } + + // Do not override the add-on id that has been already assigned. + if (!this.id) { + this.id = manifestData.id; + } + + this.manifest = manifestData.manifest; + this.apiNames = manifestData.apiNames; + this.contentScripts = manifestData.contentScripts; + this.dependencies = manifestData.dependencies; + this.permissions = manifestData.permissions; + this.schemaURLs = manifestData.schemaURLs; + this.type = manifestData.type; + + this.modules = manifestData.modules; + + this.apiManager = this.getAPIManager(); + await this.apiManager.lazyInit(); + + this.webAccessibleResources = manifestData.webAccessibleResources; + + this.originControls = manifestData.originControls; + this.allowedOrigins = new MatchPatternSet(manifestData.originPermissions, { + restrictSchemes: this.restrictSchemes, + }); + + return this.manifest; + } + + hasPermission(perm, includeOptional = false) { + // If the permission is a "manifest property" permission, we check if the extension + // does have the required property in its manifest. + let manifest_ = "manifest:"; + if (perm.startsWith(manifest_)) { + // Handle nested "manifest property" permission (e.g. as in "manifest:property.nested"). + let value = this.manifest; + for (let prop of perm.substr(manifest_.length).split(".")) { + if (!value) { + break; + } + value = value[prop]; + } + + return value != null; + } + + if (this.permissions.has(perm)) { + return true; + } + + if (includeOptional && this.manifest.optional_permissions.includes(perm)) { + return true; + } + + return false; + } + + getAPIManager() { + /** @type {(InstanceType)[]} */ + let apiManagers = [Management]; + + for (let id of this.dependencies) { + let policy = WebExtensionPolicy.getByID(id); + if (policy) { + if (policy.extension.experimentAPIManager) { + apiManagers.push(policy.extension.experimentAPIManager); + } else if (AppConstants.DEBUG) { + Cu.reportError(`Cannot find experimental API exported from ${id}`); + } + } + } + + if (this.modules) { + this.experimentAPIManager = new ExtensionCommon.LazyAPIManager( + "main", + this.modules.parent, + this.schemaURLs + ); + + apiManagers.push(this.experimentAPIManager); + } + + if (apiManagers.length == 1) { + return apiManagers[0]; + } + + return new ExtensionCommon.MultiAPIManager("main", apiManagers.reverse()); + } + + localizeMessage(...args) { + return this.localeData.localizeMessage(...args); + } + + localize(str, locale) { + // If the extension declares fluent resources in the manifest, try + // first to localize with fluent. Also use the original webextension + // method (_locales/xx.json) so extensions can migrate bit by bit. + // Note also that fluent keys typically use hyphense, so hyphens are + // allowed in the __MSG_foo__ keys used by fluent, though they are + // not allowed in the keys used for json translations. + if (this.fluentL10n) { + str = str.replace(/__MSG_([-A-Za-z0-9@_]+?)__/g, (matched, message) => { + let translation = this.fluentL10n.formatValueSync(message); + return translation !== undefined ? translation : matched; + }); + } + if (this.localeData) { + str = this.localeData.localize(str, locale); + } + return str; + } + + // If a "default_locale" is specified in that manifest, returns it + // as a Gecko-compatible locale string. Otherwise, returns null. + get defaultLocale() { + if (this.manifest.default_locale != null) { + return this.normalizeLocaleCode(this.manifest.default_locale); + } + + return null; + } + + // Returns true if an addon is builtin to Firefox or + // distributed via Normandy into a system location. + get isAppProvided() { + return this.addonData.builtIn || this.addonData.isSystem; + } + + get isHidden() { + return ( + this.addonData.locationHidden || + (this.isPrivileged && this.manifest.hidden) + ); + } + + // Normalizes a Chrome-compatible locale code to the appropriate + // Gecko-compatible variant. Currently, this means simply + // replacing underscores with hyphens. + normalizeLocaleCode(locale) { + return locale.replace(/_/g, "-"); + } + + // Reads the locale file for the given Gecko-compatible locale code, and + // stores its parsed contents in |this.localeMessages.get(locale)|. + async readLocaleFile(locale) { + let locales = await this.promiseLocales(); + let dir = locales.get(locale) || locale; + let file = `_locales/${dir}/messages.json`; + + try { + let messages = await this.readJSON(file); + return this.localeData.addLocale(locale, messages, this); + } catch (e) { + this.packagingError(`Loading locale file ${file}: ${e}`); + return new Map(); + } + } + + async _promiseLocaleMap() { + let locales = new Map(); + + let entries = await this._readDirectory("_locales", true); + for (let name of entries) { + let locale = this.normalizeLocaleCode(name); + locales.set(locale, name); + } + + return locales; + } + + _setupLocaleData(locales) { + if (this.localeData) { + return this.localeData.locales; + } + + this.localeData = new lazy.LocaleData({ + defaultLocale: this.defaultLocale, + locales, + builtinMessages: this.builtinMessages, + }); + + return locales; + } + + // Reads the list of locales available in the extension, and returns a + // Promise which resolves to a Map upon completion. + // Each map key is a Gecko-compatible locale code, and each value is the + // "_locales" subdirectory containing that locale: + // + // Map(gecko-locale-code -> locale-directory-name) + promiseLocales() { + if (!this._promiseLocales) { + this._promiseLocales = (async () => { + let locales = this._promiseLocaleMap(); + return this._setupLocaleData(locales); + })(); + } + + return this._promiseLocales; + } + + // Reads the locale messages for all locales, and returns a promise which + // resolves to a Map of locale messages upon completion. Each key in the map + // is a Gecko-compatible locale code, and each value is a locale data object + // as returned by |readLocaleFile|. + async initAllLocales() { + let locales = await this.promiseLocales(); + + await Promise.all( + Array.from(locales.keys(), locale => this.readLocaleFile(locale)) + ); + + let defaultLocale = this.defaultLocale; + if (defaultLocale) { + if (!locales.has(defaultLocale)) { + this.manifestError( + 'Value for "default_locale" property must correspond to ' + + 'a directory in "_locales/". Not found: ' + + JSON.stringify(`_locales/${this.manifest.default_locale}/`) + ); + } + } else if (locales.size) { + this.manifestError( + 'The "default_locale" property is required when a ' + + '"_locales/" directory is present.' + ); + } + + return this.localeData.messages; + } + + // Reads the locale file for the given Gecko-compatible locale code, or the + // default locale if no locale code is given, and sets it as the currently + // selected locale on success. + // + // Pre-loads the default locale for fallback message processing, regardless + // of the locale specified. + // + // If no locales are unavailable, resolves to |null|. + async initLocale(locale = this.defaultLocale) { + if (locale == null) { + return null; + } + + let promises = [this.readLocaleFile(locale)]; + + let { defaultLocale } = this; + if (locale != defaultLocale && !this.localeData.has(defaultLocale)) { + promises.push(this.readLocaleFile(defaultLocale)); + } + + let results = await Promise.all(promises); + + this.localeData.selectedLocale = locale; + return results[0]; + } + + /** + * @param {string} origin + * @returns {boolean} If this is one of the "all sites" permission. + */ + static isAllSitesPermission(origin) { + try { + let info = ExtensionData.classifyOriginPermissions([origin], true); + return !!info.allUrls; + } catch (e) { + // Passed string is not an origin permission. + return false; + } + } + + /** + * @typedef {object} HostPermissions + * @param {string} allUrls permission used to obtain all urls access + * @param {Set} wildcards set contains permissions with wildcards + * @param {Set} sites set contains explicit host permissions + * @param {Map} wildcardsMap mapping origin wildcards to labels + * @param {Map} sitesMap mapping origin patterns to labels + */ + + /** + * Classify host permissions + * + * @param {Array} origins + * permission origins + * @param {boolean} ignoreNonWebSchemes + * return only these schemes: *, http, https, ws, wss + * + * @returns {HostPermissions} + */ + static classifyOriginPermissions(origins = [], ignoreNonWebSchemes = false) { + let allUrls = null, + wildcards = new Set(), + sites = new Set(), + // TODO: use map.values() instead of these sets. Note: account for two + // match patterns producing the same permission string, see bug 1765828. + wildcardsMap = new Map(), + sitesMap = new Map(); + + // https://searchfox.org/mozilla-central/rev/6f6cf28107/toolkit/components/extensions/MatchPattern.cpp#235 + const wildcardSchemes = ["*", "http", "https", "ws", "wss"]; + + for (let permission of origins) { + if (permission == "") { + allUrls = permission; + continue; + } + + // Privileged extensions may request access to "about:"-URLs, such as + // about:reader. + let match = /^([a-z*]+):\/\/([^/]*)\/|^about:/.exec(permission); + if (!match) { + throw new Error(`Unparseable host permission ${permission}`); + } + + // Note: the scheme is ignored in the permission warnings. If this ever + // changes, update the comparePermissions method as needed. + let [, scheme, host] = match; + if (ignoreNonWebSchemes && !wildcardSchemes.includes(scheme)) { + continue; + } + + if (!host || host == "*") { + if (!allUrls) { + allUrls = permission; + } + } else if (host.startsWith("*.")) { + wildcards.add(host.slice(2)); + // Using MatchPattern to normalize the pattern string. + let pat = new MatchPattern(permission, { ignorePath: true }); + wildcardsMap.set(pat.pattern, `${scheme}://${host.slice(2)}`); + } else { + sites.add(host); + let pat = new MatchPattern(permission, { + ignorePath: true, + // Safe because used just for normalization, not for granting access. + restrictSchemes: false, + }); + sitesMap.set(pat.pattern, `${scheme}://${host}`); + } + } + return { allUrls, wildcards, sites, wildcardsMap, sitesMap }; + } + + /** + * @typedef {object} Permissions + * @property {Array} origins Origin permissions. + * @property {Array} permissions Regular (non-origin) permissions. + */ + + /** + * Formats all the strings for a permissions dialog/notification. + * + * @param {object} info Information about the permissions being requested. + * + * @param {object} [info.addon] Optional information about the addon. + * @param {Permissions} [info.optionalPermissions] + * Optional permissions listed in the manifest. + * @param {Permissions} info.permissions Requested permissions. + * @param {string} info.siteOrigin + * @param {Array} [info.sitePermissions] + * @param {boolean} info.unsigned + * True if the prompt is for installing an unsigned addon. + * @param {string} info.type + * The type of prompt being shown. May be one of "update", + * "sideload", "optional", or omitted for a regular + * install prompt. + * @param {object} options + * @param {boolean} [options.collapseOrigins] + * Wether to limit the number of displayed host permissions. + * Default is false. + * @param {boolean} [options.buildOptionalOrigins] + * Wether to build optional origins Maps for permission + * controls. Defaults to false. + * + * @returns {object} An object with properties containing localized strings + * for various elements of a permission dialog. The "header" + * property on this object is the notification header text + * and it has the string "<>" as a placeholder for the + * addon name. + * + * "object.msgs" is an array of localized strings describing required permissions + * + * "object.optionalPermissions" is a map of permission name to localized + * strings describing the permission. + * + * "object.optionalOrigins" is a map of a host permission to localized strings + * describing the host permission, where appropriate. Currently only + * all url style permissions are included. + */ + static formatPermissionStrings( + { + addon, + optionalPermissions, + permissions, + siteOrigin, + sitePermissions, + type, + unsigned, + }, + { collapseOrigins = false, buildOptionalOrigins = false } = {} + ) { + const l10n = lazy.PERMISSION_L10N; + + const msgIds = []; + const headerArgs = { extension: "<>" }; + let acceptId = "webext-perms-add"; + let cancelId = "webext-perms-cancel"; + + const result = { + msgs: [], + optionalPermissions: {}, + optionalOrigins: {}, + text: "", + listIntro: "", + }; + + // To keep the label & accesskey in sync for localizations, + // they need to be stored as attributes of the same Fluent message. + // This unpacks them into the shape expected of them in `result`. + function setAcceptCancel(acceptId, cancelId) { + const haveAccessKeys = AppConstants.platform !== "android"; + + const [accept, cancel] = l10n.formatMessagesSync([ + { id: acceptId }, + { id: cancelId }, + ]); + + for (let { name, value } of accept.attributes) { + if (name === "label") { + result.acceptText = value; + } else if (name === "accesskey" && haveAccessKeys) { + result.acceptKey = value; + } + } + + for (let { name, value } of cancel.attributes) { + if (name === "label") { + result.cancelText = value; + } else if (name === "accesskey" && haveAccessKeys) { + result.cancelKey = value; + } + } + } + + // Synthetic addon install can only grant access to a single permission so we can have + // a less-generic message than addons with site permissions. + // NOTE: this is used as part of the synthetic addon install flow implemented for the + // SitePermissionAddonProvider. + // (and so it should not be removed as part of Bug 1789718 changes, while this additional note should be). + // FIXME + if (addon?.type === lazy.SITEPERMS_ADDON_TYPE) { + // We simplify the origin to make it more user friendly. The origin is assured to be + // available because the SitePermsAddon install is always expected to be triggered + // from a website, making the siteOrigin always available through the installing principal. + headerArgs.hostname = new URL(siteOrigin).hostname; + + // messages are specific to the type of gated permission being installed + const headerId = + sitePermissions[0] === "midi-sysex" + ? "webext-site-perms-header-with-gated-perms-midi-sysex" + : "webext-site-perms-header-with-gated-perms-midi"; + result.header = l10n.formatValueSync(headerId, headerArgs); + + // We use the same string for midi and midi-sysex, and don't support any + // other types of site permission add-ons. So we just hard-code the + // descriptor for now. See bug 1826747. + result.text = l10n.formatValueSync( + "webext-site-perms-description-gated-perms-midi" + ); + + setAcceptCancel(acceptId, cancelId); + return result; + } + + // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed. + if (sitePermissions) { + for (let permission of sitePermissions) { + let permMsg; + switch (permission) { + case "midi": + permMsg = l10n.formatValueSync("webext-site-perms-midi"); + break; + case "midi-sysex": + permMsg = l10n.formatValueSync("webext-site-perms-midi-sysex"); + break; + default: + Cu.reportError( + `site_permission ${permission} missing readable text property` + ); + // We must never have a DOM api permission that is hidden so in + // the case of any error, we'll use the plain permission string. + // test_ext_sitepermissions.js tests for no missing messages, this + // is just an extra fallback. + permMsg = permission; + } + result.msgs.push(permMsg); + } + + // We simplify the origin to make it more user friendly. The origin is + // assured to be available via schema requirement. + headerArgs.hostname = new URL(siteOrigin).hostname; + + const headerId = unsigned + ? "webext-site-perms-header-unsigned-with-perms" + : "webext-site-perms-header-with-perms"; + result.header = l10n.formatValueSync(headerId, headerArgs); + setAcceptCancel(acceptId, cancelId); + return result; + } + + if (permissions) { + // First classify our host permissions + let { allUrls, wildcards, sites } = + ExtensionData.classifyOriginPermissions(permissions.origins); + + // Format the host permissions. If we have a wildcard for all urls, + // a single string will suffice. Otherwise, show domain wildcards + // first, then individual host permissions. + if (allUrls) { + msgIds.push("webext-perms-host-description-all-urls"); + } else { + // Formats a list of host permissions. If we have 4 or fewer, display + // them all, otherwise display the first 3 followed by an item that + // says "...plus N others" + const addMessages = (set, l10nId, moreL10nId) => { + if (collapseOrigins && set.size > 4) { + for (let domain of Array.from(set).slice(0, 3)) { + msgIds.push({ id: l10nId, args: { domain } }); + } + msgIds.push({ + id: moreL10nId, + args: { domainCount: set.size - 3 }, + }); + } else { + for (let domain of set) { + msgIds.push({ id: l10nId, args: { domain } }); + } + } + }; + + addMessages( + wildcards, + "webext-perms-host-description-wildcard", + "webext-perms-host-description-too-many-wildcards" + ); + addMessages( + sites, + "webext-perms-host-description-one-site", + "webext-perms-host-description-too-many-sites" + ); + } + + // Finally, show remaining permissions, in the same order as AMO. + // The permissions are sorted alphabetically by the permission + // string to match AMO. + // Show the native messaging permission first if it is present. + const NATIVE_MSG_PERM = "nativeMessaging"; + const permissionsSorted = permissions.permissions.sort((a, b) => { + if (a === NATIVE_MSG_PERM) { + return -1; + } else if (b === NATIVE_MSG_PERM) { + return 1; + } + return a < b ? -1 : 1; + }); + for (let permission of permissionsSorted) { + const l10nId = lazy.permissionToL10nId(permission); + // We deliberately do not include all permissions in the prompt. + // So if we don't find one then just skip it. + if (l10nId) { + msgIds.push(l10nId); + } + } + } + + if (optionalPermissions) { + // Generate a map of permission names to permission strings for optional + // permissions. The key is necessary to handle toggling those permissions. + const opKeys = []; + const opL10nIds = []; + for (let permission of optionalPermissions.permissions) { + const l10nId = lazy.permissionToL10nId(permission); + // We deliberately do not include all permissions in the prompt. + // So if we don't find one then just skip it. + if (l10nId) { + opKeys.push(permission); + opL10nIds.push(l10nId); + } + } + if (opKeys.length) { + const opRes = l10n.formatValuesSync(opL10nIds); + for (let i = 0; i < opKeys.length; ++i) { + result.optionalPermissions[opKeys[i]] = opRes[i]; + } + } + + const { allUrls, sitesMap, wildcardsMap } = + ExtensionData.classifyOriginPermissions( + optionalPermissions.origins, + true + ); + const ooKeys = []; + const ooL10nIds = []; + if (allUrls) { + ooKeys.push(allUrls); + ooL10nIds.push("webext-perms-host-description-all-urls"); + } + + // Current UX controls are meant for developer testing with mv3. + if (buildOptionalOrigins) { + for (let [pattern, domain] of wildcardsMap.entries()) { + ooKeys.push(pattern); + ooL10nIds.push({ + id: "webext-perms-host-description-wildcard", + args: { domain }, + }); + } + for (let [pattern, domain] of sitesMap.entries()) { + ooKeys.push(pattern); + ooL10nIds.push({ + id: "webext-perms-host-description-one-site", + args: { domain }, + }); + } + } + + if (ooKeys.length) { + const res = l10n.formatValuesSync(ooL10nIds); + for (let i = 0; i < res.length; ++i) { + result.optionalOrigins[ooKeys[i]] = res[i]; + } + } + } + + let headerId; + switch (type) { + case "sideload": + headerId = "webext-perms-sideload-header"; + acceptId = "webext-perms-sideload-enable"; + cancelId = "webext-perms-sideload-cancel"; + result.text = l10n.formatValueSync( + msgIds.length + ? "webext-perms-sideload-text" + : "webext-perms-sideload-text-no-perms" + ); + break; + case "update": + headerId = "webext-perms-update-text"; + acceptId = "webext-perms-update-accept"; + break; + case "optional": + headerId = "webext-perms-optional-perms-header"; + acceptId = "webext-perms-optional-perms-allow"; + cancelId = "webext-perms-optional-perms-deny"; + result.listIntro = l10n.formatValueSync( + "webext-perms-optional-perms-list-intro" + ); + break; + default: + if (msgIds.length) { + headerId = unsigned + ? "webext-perms-header-unsigned-with-perms" + : "webext-perms-header-with-perms"; + } else { + headerId = unsigned + ? "webext-perms-header-unsigned" + : "webext-perms-header"; + } + } + + result.header = l10n.formatValueSync(headerId, headerArgs); + result.msgs = l10n.formatValuesSync(msgIds); + setAcceptCancel(acceptId, cancelId); + return result; + } +} + +const PROXIED_EVENTS = new Set([ + "test-harness-message", + "background-script-suspend", + "background-script-suspend-canceled", + "background-script-suspend-ignored", +]); + +class BootstrapScope { + install(data, reason) {} + uninstall(data, reason) { + lazy.AsyncShutdown.profileChangeTeardown.addBlocker( + `Uninstalling add-on: ${data.id}`, + Management.emit("uninstall", { id: data.id }).then(() => { + Management.emit("uninstall-complete", { id: data.id }); + }) + ); + } + + fetchState() { + if (this.extension) { + return { state: this.extension.state }; + } + return null; + } + + async update(data, reason) { + // For updates that happen during startup, such as sideloads + // and staged updates, the extension startupReason will be + // APP_STARTED. In some situations, such as background and + // persisted listeners, we also need to know that the addon + // was updated. + this.updateReason = BootstrapScope.BOOTSTRAP_REASON_MAP[reason]; + // Retain any previously granted permissions that may have migrated + // into the optional list. + if (data.oldPermissions) { + // New permissions may be null, ensure we have an empty + // permission set in that case. + let emptyPermissions = { permissions: [], origins: [] }; + await ExtensionData.migratePermissions( + data.id, + data.oldPermissions, + data.oldOptionalPermissions, + data.userPermissions || emptyPermissions, + data.optionalPermissions || emptyPermissions + ); + } + + return Management.emit("update", { + id: data.id, + resourceURI: data.resourceURI, + isPrivileged: data.isPrivileged, + }); + } + + startup(data, reason) { + // eslint-disable-next-line no-use-before-define + this.extension = new Extension( + data, + BootstrapScope.BOOTSTRAP_REASON_MAP[reason], + this.updateReason + ); + return this.extension.startup(); + } + + async shutdown(data, reason) { + let result = await this.extension.shutdown( + BootstrapScope.BOOTSTRAP_REASON_MAP[reason] + ); + this.extension = null; + return result; + } + + static get BOOTSTRAP_REASON_MAP() { + const BR = lazy.AddonManagerPrivate.BOOTSTRAP_REASONS; + const value = Object.freeze({ + [BR.APP_STARTUP]: "APP_STARTUP", + [BR.APP_SHUTDOWN]: "APP_SHUTDOWN", + [BR.ADDON_ENABLE]: "ADDON_ENABLE", + [BR.ADDON_DISABLE]: "ADDON_DISABLE", + [BR.ADDON_INSTALL]: "ADDON_INSTALL", + [BR.ADDON_UNINSTALL]: "ADDON_UNINSTALL", + [BR.ADDON_UPGRADE]: "ADDON_UPGRADE", + [BR.ADDON_DOWNGRADE]: "ADDON_DOWNGRADE", + }); + return redefineGetter(this, "BOOTSTRAP_REASON_TO_STRING_MAP", value); + } +} + +class DictionaryBootstrapScope extends BootstrapScope { + install(data, reason) {} + uninstall(data, reason) {} + + startup(data, reason) { + // eslint-disable-next-line no-use-before-define + this.dictionary = new Dictionary(data); + return this.dictionary.startup(BootstrapScope.BOOTSTRAP_REASON_MAP[reason]); + } + + async shutdown(data, reason) { + this.dictionary.shutdown(BootstrapScope.BOOTSTRAP_REASON_MAP[reason]); + this.dictionary = null; + } +} + +class LangpackBootstrapScope extends BootstrapScope { + install(data, reason) {} + uninstall(data, reason) {} + async update(data, reason) {} + + startup(data, reason) { + // eslint-disable-next-line no-use-before-define + this.langpack = new Langpack(data); + return this.langpack.startup(BootstrapScope.BOOTSTRAP_REASON_MAP[reason]); + } + + async shutdown(data, reason) { + this.langpack.shutdown(BootstrapScope.BOOTSTRAP_REASON_MAP[reason]); + this.langpack = null; + } +} + +// TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed. +class SitePermissionBootstrapScope extends BootstrapScope { + install(data, reason) {} + uninstall(data, reason) {} + + startup(data, reason) { + // eslint-disable-next-line no-use-before-define + this.sitepermission = new SitePermission(data); + return this.sitepermission.startup( + BootstrapScope.BOOTSTRAP_REASON_MAP[reason] + ); + } + + async shutdown(data, reason) { + this.sitepermission.shutdown(BootstrapScope.BOOTSTRAP_REASON_MAP[reason]); + this.sitepermission = null; + } +} + +let activeExtensionIDs = new Set(); + +let pendingExtensions = new Map(); + +/** + * This class is the main representation of an active WebExtension + * in the main process. + * + * @augments ExtensionData + */ +export class Extension extends ExtensionData { + /** @type {Map>} */ + persistentListeners; + + constructor(addonData, startupReason, updateReason) { + super(addonData.resourceURI, addonData.isPrivileged); + + this.startupStates = new Set(); + this.state = "Not started"; + this.userContextIsolation = lazy.userContextIsolation; + + this.sharedDataKeys = new Set(); + + this.uuid = UUIDMap.get(addonData.id); + this.instanceId = getUniqueId(); + + this.MESSAGE_EMIT_EVENT = `Extension:EmitEvent:${this.instanceId}`; + Services.ppmm.addMessageListener(this.MESSAGE_EMIT_EVENT, this); + + if (addonData.cleanupFile) { + Services.obs.addObserver(this, "xpcom-shutdown"); + this.cleanupFile = addonData.cleanupFile || null; + delete addonData.cleanupFile; + } + + if (addonData.TEST_NO_ADDON_MANAGER) { + this.dontSaveStartupData = true; + } + if (addonData.TEST_NO_DELAYED_STARTUP) { + this.testNoDelayedStartup = true; + } + + this.addonData = addonData; + this.startupData = addonData.startupData || {}; + this.startupReason = startupReason; + this.updateReason = updateReason; + this.temporarilyInstalled = !!addonData.temporarilyInstalled; + + if ( + updateReason || + ["ADDON_UPGRADE", "ADDON_DOWNGRADE"].includes(startupReason) + ) { + this.startupClearCachePromise = StartupCache.clearAddonData(addonData.id); + } + + this.remote = !WebExtensionPolicy.isExtensionProcess; + this.remoteType = this.remote ? lazy.E10SUtils.EXTENSION_REMOTE_TYPE : null; + + if (this.remote && lazy.processCount !== 1) { + throw new Error( + "Out-of-process WebExtensions are not supported with multiple child processes" + ); + } + + // This is filled in the first time an extension child is created. + this.parentMessageManager = null; + + this.id = addonData.id; + this.version = addonData.version; + this.baseURL = this.getURL(""); + this.baseURI = Services.io.newURI(this.baseURL).QueryInterface(Ci.nsIURL); + this.principal = this.createPrincipal(); + + // Privileged extensions and any extensions with a recommendation state are + // exempt from the quarantined domains. + // NOTE: privileged extensions are also exempted from quarantined domains + // by the WebExtensionPolicy internal logic and so ignoreQuarantine set to + // false for a privileged extension does not make any difference in + // practice (but we still set the ignoreQuarantine flag here accordingly + // to the expected behavior for consistency). + this.ignoreQuarantine = + addonData.isPrivileged || + !!addonData.recommendationState?.states?.length || + lazy.QuarantinedDomains.isUserAllowedAddonId(this.id); + + this.views = new Set(); + this._backgroundPageFrameLoader = null; + + this.onStartup = null; + + this.hasShutdown = false; + this.onShutdown = new Set(); + + this.uninstallURL = null; + + this.allowedOrigins = null; + this._optionalOrigins = null; + this.webAccessibleResources = null; + + this.registeredContentScripts = new Map(); + + this.emitter = new EventEmitter(); + + if (this.startupData.lwtData && this.startupReason == "APP_STARTUP") { + lazy.LightweightThemeManager.fallbackThemeData = this.startupData.lwtData; + } + + /* eslint-disable mozilla/balanced-listeners */ + this.on("add-permissions", (ignoreEvent, permissions) => { + for (let perm of permissions.permissions) { + this.permissions.add(perm); + } + this.policy.permissions = Array.from(this.permissions); + + updateAllowedOrigins(this.policy, permissions.origins, /* isAdd */ true); + this.allowedOrigins = this.policy.allowedOrigins; + + if (this.policy.active) { + this.setSharedData("", this.serialize()); + Services.ppmm.sharedData.flush(); + this.broadcast("Extension:UpdatePermissions", { + id: this.id, + origins: permissions.origins, + permissions: permissions.permissions, + add: true, + }); + } + + this.cachePermissions(); + this.updatePermissions(); + }); + + this.on("remove-permissions", (ignoreEvent, permissions) => { + for (let perm of permissions.permissions) { + this.permissions.delete(perm); + } + this.policy.permissions = Array.from(this.permissions); + + updateAllowedOrigins(this.policy, permissions.origins, /* isAdd */ false); + this.allowedOrigins = this.policy.allowedOrigins; + + if (this.policy.active) { + this.setSharedData("", this.serialize()); + Services.ppmm.sharedData.flush(); + this.broadcast("Extension:UpdatePermissions", { + id: this.id, + origins: permissions.origins, + permissions: permissions.permissions, + add: false, + }); + } + + this.cachePermissions(); + this.updatePermissions(); + }); + /* eslint-enable mozilla/balanced-listeners */ + } + + set state(startupState) { + this.startupStates.clear(); + this.startupStates.add(startupState); + } + + get state() { + return `${Array.from(this.startupStates).join(", ")}`; + } + + async addStartupStatePromise(name, fn) { + this.startupStates.add(name); + try { + await fn(); + } finally { + this.startupStates.delete(name); + } + } + + // Some helpful properties added elsewhere: + + static getBootstrapScope() { + return new BootstrapScope(); + } + + get browsingContextGroupId() { + return this.policy.browsingContextGroupId; + } + + get groupFrameLoader() { + let frameLoader = this._backgroundPageFrameLoader; + for (let view of this.views) { + if (view.viewType === "background" && view.xulBrowser) { + return view.xulBrowser.frameLoader; + } + if (!frameLoader && view.xulBrowser) { + frameLoader = view.xulBrowser.frameLoader; + } + } + return frameLoader || ExtensionParent.DebugUtils.getFrameLoader(this.id); + } + + get backgroundContext() { + for (let view of this.views) { + if (view.isBackgroundContext) { + return view; + } + } + return undefined; + } + + on(hook, f) { + return this.emitter.on(hook, f); + } + + off(hook, f) { + return this.emitter.off(hook, f); + } + + once(hook, f) { + return this.emitter.once(hook, f); + } + + emit(event, ...args) { + if (PROXIED_EVENTS.has(event)) { + Services.ppmm.broadcastAsyncMessage(this.MESSAGE_EMIT_EVENT, { + event, + args, + }); + } + + return this.emitter.emit(event, ...args); + } + + receiveMessage({ name, data }) { + if (name === this.MESSAGE_EMIT_EVENT) { + this.emitter.emit(data.event, ...data.args); + } + } + + testMessage(...args) { + this.emit("test-harness-message", ...args); + } + + createPrincipal(uri = this.baseURI, originAttributes = {}) { + return Services.scriptSecurityManager.createContentPrincipal( + uri, + originAttributes + ); + } + + // Checks that the given URL is a child of our baseURI. + isExtensionURL(url) { + let uri = Services.io.newURI(url); + + let common = this.baseURI.getCommonBaseSpec(uri); + return common == this.baseURL; + } + + checkLoadURI(uri, options = {}) { + return ExtensionCommon.checkLoadURI(uri, this.principal, options); + } + + // Note: use checkLoadURI instead of checkLoadURL if you already have a URI. + checkLoadURL(url, options = {}) { + // As an optimization, if the URL starts with the extension's base URL, + // don't do any further checks. It's always allowed to load it. + if (url.startsWith(this.baseURL)) { + return true; + } + + return ExtensionCommon.checkLoadURL(url, this.principal, options); + } + + async promiseLocales(locale) { + let locales = await StartupCache.locales.get( + [this.id, "@@all_locales"], + () => this._promiseLocaleMap() + ); + + return this._setupLocaleData(locales); + } + + readLocaleFile(locale) { + return StartupCache.locales + .get([this.id, this.version, locale], () => super.readLocaleFile(locale)) + .then(result => { + this.localeData.messages.set(locale, result); + }); + } + + get manifestCacheKey() { + return [this.id, this.version, Services.locale.appLocaleAsBCP47]; + } + + saveStartupData() { + if (this.dontSaveStartupData) { + return; + } + lazy.AddonManagerPrivate.setAddonStartupData(this.id, this.startupData); + } + + async parseManifest() { + await this.startupClearCachePromise; + return StartupCache.manifests.get(this.manifestCacheKey, () => + super.parseManifest() + ); + } + + async cachePermissions() { + let manifestData = await this.parseManifest(); + + manifestData.originPermissions = this.allowedOrigins.patterns.map( + pat => pat.pattern + ); + manifestData.permissions = this.permissions; + return StartupCache.manifests.set(this.manifestCacheKey, manifestData); + } + + async loadManifest() { + let manifest = await super.loadManifest(); + + this.ensureNoErrors(); + + return manifest; + } + + get extensionPageCSP() { + const { content_security_policy } = this.manifest; + // While only manifest v3 should contain an object, + // we'll remain lenient here. + if ( + content_security_policy && + typeof content_security_policy === "object" + ) { + return content_security_policy.extension_pages; + } + return content_security_policy; + } + + get backgroundScripts() { + return this.manifest.background?.scripts; + } + + get backgroundTypeModule() { + return this.manifest.background?.type === "module"; + } + + get backgroundWorkerScript() { + return this.manifest.background?.service_worker; + } + + get optionalPermissions() { + return this.manifest.optional_permissions; + } + + get privateBrowsingAllowed() { + return this.policy.privateBrowsingAllowed; + } + + canAccessWindow(window) { + return this.policy.canAccessWindow(window); + } + + // TODO bug 1699481: move this logic to WebExtensionPolicy + canAccessContainer(userContextId) { + userContextId = userContextId ?? 0; // firefox-default has userContextId as 0. + let defaultRestrictedContainers = JSON.parse( + lazy.userContextIsolationDefaultRestricted + ); + let extensionRestrictedContainers = JSON.parse( + Services.prefs.getStringPref( + `extensions.userContextIsolation.${this.id}.restricted`, + "[]" + ) + ); + if ( + extensionRestrictedContainers.includes(userContextId) || + defaultRestrictedContainers.includes(userContextId) + ) { + return false; + } + + return true; + } + + // Representation of the extension to send to content + // processes. This should include anything the content process might + // need. + serialize() { + return { + id: this.id, + uuid: this.uuid, + name: this.name, + type: this.type, + manifestVersion: this.manifestVersion, + extensionPageCSP: this.extensionPageCSP, + instanceId: this.instanceId, + resourceURL: this.resourceURL, + contentScripts: this.contentScripts, + webAccessibleResources: this.webAccessibleResources, + allowedOrigins: this.allowedOrigins.patterns.map(pat => pat.pattern), + permissions: this.permissions, + optionalPermissions: this.optionalPermissions, + isPrivileged: this.isPrivileged, + ignoreQuarantine: this.ignoreQuarantine, + temporarilyInstalled: this.temporarilyInstalled, + }; + } + + // Extended serialized data which is only needed in the extensions process, + // and is never deserialized in web content processes. + // Keep in sync with BrowserExtensionContent in ExtensionChild.jsm + serializeExtended() { + return { + backgroundScripts: this.backgroundScripts, + backgroundWorkerScript: this.backgroundWorkerScript, + backgroundTypeModule: this.backgroundTypeModule, + childModules: this.modules && this.modules.child, + dependencies: this.dependencies, + persistentBackground: this.persistentBackground, + schemaURLs: this.schemaURLs, + }; + } + + broadcast(msg, data) { + return new Promise(resolve => { + let { ppmm } = Services; + let children = new Set(); + for (let i = 0; i < ppmm.childCount; i++) { + children.add(ppmm.getChildAt(i)); + } + + let maybeResolve; + function listener(data) { + children.delete(data.target); + maybeResolve(); + } + function observer(subject, topic, data) { + children.delete(subject); + maybeResolve(); + } + + maybeResolve = () => { + if (children.size === 0) { + ppmm.removeMessageListener(msg + "Complete", listener); + Services.obs.removeObserver(observer, "message-manager-close"); + Services.obs.removeObserver(observer, "message-manager-disconnect"); + resolve(); + } + }; + ppmm.addMessageListener(msg + "Complete", listener, true); + Services.obs.addObserver(observer, "message-manager-close"); + Services.obs.addObserver(observer, "message-manager-disconnect"); + + ppmm.broadcastAsyncMessage(msg, data); + }); + } + + setSharedData(key, value) { + key = `extension/${this.id}/${key}`; + this.sharedDataKeys.add(key); + + sharedData.set(key, value); + } + + getSharedData(key, value) { + key = `extension/${this.id}/${key}`; + return sharedData.get(key); + } + + initSharedData() { + this.setSharedData("", this.serialize()); + this.setSharedData("extendedData", this.serializeExtended()); + this.setSharedData("locales", this.localeData.serialize()); + this.setSharedData("manifest", this.manifest); + this.updateContentScripts(); + } + + updateContentScripts() { + this.setSharedData("contentScripts", this.registeredContentScripts); + } + + runManifest(manifest) { + let promises = []; + let addPromise = (name, fn) => { + promises.push(this.addStartupStatePromise(name, fn)); + }; + + for (let directive in manifest) { + if (manifest[directive] !== null) { + addPromise(`asyncEmitManifestEntry("${directive}")`, () => + Management.asyncEmitManifestEntry(this, directive) + ); + } + } + + activeExtensionIDs.add(this.id); + sharedData.set("extensions/activeIDs", activeExtensionIDs); + + pendingExtensions.delete(this.id); + sharedData.set("extensions/pending", pendingExtensions); + + Services.ppmm.sharedData.flush(); + this.broadcast("Extension:Startup", this.id); + + return Promise.all(promises); + } + + /** + * Call the close() method on the given object when this extension + * is shut down. This can happen during browser shutdown, or when + * an extension is manually disabled or uninstalled. + * + * @param {object} obj + * An object on which to call the close() method when this + * extension is shut down. + */ + callOnClose(obj) { + this.onShutdown.add(obj); + } + + forgetOnClose(obj) { + this.onShutdown.delete(obj); + } + + get builtinMessages() { + return new Map([["@@extension_id", this.uuid]]); + } + + // Reads the locale file for the given Gecko-compatible locale code, or if + // no locale is given, the available locale closest to the UI locale. + // Sets the currently selected locale on success. + async initLocale(locale = undefined) { + if (locale === undefined) { + let locales = await this.promiseLocales(); + + let matches = Services.locale.negotiateLanguages( + Services.locale.appLocalesAsBCP47, + Array.from(locales.keys()), + this.defaultLocale + ); + + locale = matches[0]; + } + + return super.initLocale(locale); + } + + /** + * Clear cached resources associated to the extension principal + * when an extension is installed (in case we were unable to do that at + * uninstall time) or when it is being upgraded or downgraded. + * + * @param {string|undefined} reason + * BOOTSTRAP_REASON string, if provided. The value is expected to be + * `undefined` for extension objects without a corresponding AddonManager + * addon wrapper (e.g. test extensions created using `ExtensionTestUtils` + * without `useAddonManager` optional property). + * + * @returns {Promise} + * Promise resolved when the nsIClearDataService async method call + * has been completed. + */ + async clearCache(reason) { + switch (reason) { + case "ADDON_INSTALL": + case "ADDON_UPGRADE": + case "ADDON_DOWNGRADE": + return clearCacheForExtensionPrincipal(this.principal); + } + } + + /** + * Update site permissions as necessary. + * + * @param {string} [reason] + * If provided, this is a BOOTSTRAP_REASON string. If reason is undefined, + * addon permissions are being added or removed that may effect the site permissions. + */ + updatePermissions(reason) { + const { principal } = this; + + const testPermission = perm => + Services.perms.testPermissionFromPrincipal(principal, perm); + + const addUnlimitedStoragePermissions = () => { + // Set the indexedDB permission and a custom "WebExtensions-unlimitedStorage" to + // remember that the permission hasn't been selected manually by the user. + Services.perms.addFromPrincipal( + principal, + "WebExtensions-unlimitedStorage", + Services.perms.ALLOW_ACTION + ); + Services.perms.addFromPrincipal( + principal, + "persistent-storage", + Services.perms.ALLOW_ACTION + ); + }; + + // Only update storage permissions when the extension changes in + // some way. + if (reason !== "APP_STARTUP" && reason !== "APP_SHUTDOWN") { + if (this.hasPermission("unlimitedStorage")) { + addUnlimitedStoragePermissions(); + } else { + // Remove the indexedDB permission if it has been enabled using the + // unlimitedStorage WebExtensions permissions. + Services.perms.removeFromPrincipal( + principal, + "WebExtensions-unlimitedStorage" + ); + Services.perms.removeFromPrincipal(principal, "persistent-storage"); + } + } else if ( + reason === "APP_STARTUP" && + this.hasPermission("unlimitedStorage") && + testPermission("persistent-storage") !== Services.perms.ALLOW_ACTION + ) { + // If the extension does have the unlimitedStorage permission, but the + // expected site permissions are missing during the app startup, then + // add them back (See Bug 1454192). + addUnlimitedStoragePermissions(); + } + + // Never change geolocation permissions at shutdown, since it uses a + // session-only permission. + if (reason !== "APP_SHUTDOWN") { + if (this.hasPermission("geolocation")) { + if (testPermission("geo") === Services.perms.UNKNOWN_ACTION) { + Services.perms.addFromPrincipal( + principal, + "geo", + Services.perms.ALLOW_ACTION, + Services.perms.EXPIRE_SESSION + ); + } + } else if ( + reason !== "APP_STARTUP" && + testPermission("geo") === Services.perms.ALLOW_ACTION + ) { + Services.perms.removeFromPrincipal(principal, "geo"); + } + } + } + + async startup() { + this.state = "Startup"; + + // readyPromise is resolved with the policy upon success, + // and with null if startup was interrupted. + /** @type {callback} */ + let resolveReadyPromise; + let readyPromise = new Promise(resolve => { + resolveReadyPromise = resolve; + }); + + // Create a temporary policy object for the devtools and add-on + // manager callers that depend on it being available early. + this.policy = new WebExtensionPolicy({ + id: this.id, + mozExtensionHostname: this.uuid, + baseURL: this.resourceURL, + isPrivileged: this.isPrivileged, + ignoreQuarantine: this.ignoreQuarantine, + temporarilyInstalled: this.temporarilyInstalled, + allowedOrigins: new MatchPatternSet([]), + localizeCallback() {}, + readyPromise, + }); + + this.policy.extension = this; + if (!WebExtensionPolicy.getByID(this.id)) { + this.policy.active = true; + } + + pendingExtensions.set(this.id, { + mozExtensionHostname: this.uuid, + baseURL: this.resourceURL, + isPrivileged: this.isPrivileged, + ignoreQuarantine: this.ignoreQuarantine, + }); + sharedData.set("extensions/pending", pendingExtensions); + + lazy.ExtensionTelemetry.extensionStartup.stopwatchStart(this); + try { + this.state = "Startup: Loading manifest"; + await this.loadManifest(); + this.state = "Startup: Loaded manifest"; + + if (!this.hasShutdown) { + this.state = "Startup: Init locale"; + await this.initLocale(); + this.state = "Startup: Initted locale"; + } + + this.ensureNoErrors(); + + if (this.hasShutdown) { + // Startup was interrupted and shutdown() has taken care of unloading + // the extension and running cleanup logic. + return; + } + + await this.clearCache(this.startupReason); + this._setupStartupPermissions(); + + GlobalManager.init(this); + + if (this.hasPermission("scripting")) { + this.state = "Startup: Initialize scripting store"; + // We have to await here because `initSharedData` depends on the data + // fetched from the scripting store. This has to be done early because + // we need the data to run the content scripts in existing pages at + // startup. + try { + await lazy.ExtensionScriptingStore.initExtension(this); + this.state = "Startup: Scripting store initialized"; + } catch (err) { + this.logError(`Failed to initialize scripting store: ${err}`); + } + } + + this.initSharedData(); + + this.policy.active = false; + this.policy = lazy.ExtensionProcessScript.initExtension(this); + this.policy.extension = this; + + this.updatePermissions(this.startupReason); + + // Select the storage.local backend if it is already known, + // and start the data migration if needed. + if (this.hasPermission("storage")) { + if (!lazy.ExtensionStorageIDB.isBackendEnabled) { + this.setSharedData("storageIDBBackend", false); + } else if (lazy.ExtensionStorageIDB.isMigratedExtension(this)) { + this.setSharedData("storageIDBBackend", true); + this.setSharedData( + "storageIDBPrincipal", + lazy.ExtensionStorageIDB.getStoragePrincipal(this) + ); + } else if ( + this.startupReason === "ADDON_INSTALL" && + !Services.prefs.getBoolPref(LEAVE_STORAGE_PREF, false) + ) { + // If the extension has been just installed, set it as migrated, + // because there will not be any data to migrate. + lazy.ExtensionStorageIDB.setMigratedExtensionPref(this, true); + this.setSharedData("storageIDBBackend", true); + this.setSharedData( + "storageIDBPrincipal", + lazy.ExtensionStorageIDB.getStoragePrincipal(this) + ); + } + } + + // Initialize DNR for the extension, only if the extension + // has the required DNR permissions and without blocking + // the extension startup on DNR being fully initialized. + if ( + this.hasPermission("declarativeNetRequest") || + this.hasPermission("declarativeNetRequestWithHostAccess") + ) { + lazy.ExtensionDNR.ensureInitialized(this); + } + + resolveReadyPromise(this.policy); + + // The "startup" Management event sent on the extension instance itself + // is emitted just before the Management "startup" event, + // and it is used to run code that needs to be executed before + // any of the "startup" listeners. + this.emit("startup", this); + + this.startupStates.clear(); + await Promise.all([ + this.addStartupStatePromise("Startup: Emit startup", () => + Management.emit("startup", this) + ), + this.addStartupStatePromise("Startup: Run manifest", () => + this.runManifest(this.manifest) + ), + ]); + this.state = "Startup: Ran manifest"; + + Management.emit("ready", this); + this.emit("ready"); + + this.state = "Startup: Complete"; + } catch (e) { + this.state = `Startup: Error: ${e}`; + + Cu.reportError(e); + + if (this.policy) { + this.policy.active = false; + } + + this.cleanupGeneratedFile(); + + throw e; + } finally { + lazy.ExtensionTelemetry.extensionStartup.stopwatchFinish(this); + // Mark readyPromise as resolved in case it has not happened before, + // e.g. due to an early return or an error. + resolveReadyPromise(null); + } + } + + // Setup initial permissions on extension startup based on manifest + // and potentially previous manifest and permissions values. None of + // the ExtensionPermissions.add/remove() calls are are awaited here + // because we update the in-memory representation at the same time. + _setupStartupPermissions() { + // If we add/remove permissions conditionally based on startupReason, + // we need to update the cache, or changes will be lost after restart. + let updateCache = false; + + // We automatically add permissions to system/built-in extensions. + // Extensions expliticy stating not_allowed will never get permission. + let isAllowed = this.permissions.has(PRIVATE_ALLOWED_PERMISSION); + if (this.manifest.incognito === "not_allowed") { + // If an extension previously had permission, but upgrades/downgrades to + // a version that specifies "not_allowed" in manifest, remove the + // permission. + if (isAllowed) { + lazy.ExtensionPermissions.remove(this.id, { + permissions: [PRIVATE_ALLOWED_PERMISSION], + origins: [], + }); + this.permissions.delete(PRIVATE_ALLOWED_PERMISSION); + } + } else if (!isAllowed && this.isPrivileged && !this.temporarilyInstalled) { + // Add to EP so it is preserved after ADDON_INSTALL. + lazy.ExtensionPermissions.add(this.id, { + permissions: [PRIVATE_ALLOWED_PERMISSION], + origins: [], + }); + this.permissions.add(PRIVATE_ALLOWED_PERMISSION); + } + + // Allow other extensions to access static themes in private browsing windows + // (See Bug 1790115). + if (this.type === "theme") { + this.permissions.add(PRIVATE_ALLOWED_PERMISSION); + } + + // We only want to update the SVG_CONTEXT_PROPERTIES_PERMISSION during + // install and upgrade/downgrade startups. + if (INSTALL_AND_UPDATE_STARTUP_REASONS.has(this.startupReason)) { + if (isMozillaExtension(this)) { + // Add to EP so it is preserved after ADDON_INSTALL. + lazy.ExtensionPermissions.add(this.id, { + permissions: [SVG_CONTEXT_PROPERTIES_PERMISSION], + origins: [], + }); + this.permissions.add(SVG_CONTEXT_PROPERTIES_PERMISSION); + } else { + lazy.ExtensionPermissions.remove(this.id, { + permissions: [SVG_CONTEXT_PROPERTIES_PERMISSION], + origins: [], + }); + this.permissions.delete(SVG_CONTEXT_PROPERTIES_PERMISSION); + } + updateCache = true; + } + + // Ensure devtools permission is set. + if ( + this.manifest.devtools_page && + !this.manifest.optional_permissions.includes("devtools") + ) { + lazy.ExtensionPermissions.add(this.id, { + permissions: ["devtools"], + origins: [], + }); + this.permissions.add("devtools"); + } + + if ( + this.originControls && + this.manifest.granted_host_permissions && + this.startupReason === "ADDON_INSTALL" + ) { + let origins = this.getManifestOrigins(); + lazy.ExtensionPermissions.add(this.id, { permissions: [], origins }); + updateCache = true; + + let allowed = this.allowedOrigins.patterns.map(p => p.pattern); + this.allowedOrigins = new MatchPatternSet(origins.concat(allowed), { + restrictSchemes: this.restrictSchemes, + ignorePath: true, + }); + } + + if (updateCache) { + this.cachePermissions(); + } + } + + cleanupGeneratedFile() { + if (!this.cleanupFile) { + return; + } + + let file = this.cleanupFile; + this.cleanupFile = null; + + Services.obs.removeObserver(this, "xpcom-shutdown"); + + return this.broadcast("Extension:FlushJarCache", { path: file.path }) + .then(() => { + // We can't delete this file until everyone using it has + // closed it (because Windows is dumb). So we wait for all the + // child processes (including the parent) to flush their JAR + // caches. These caches may keep the file open. + file.remove(false); + }) + .catch(Cu.reportError); + } + + async shutdown(reason) { + this.state = "Shutdown"; + + this.hasShutdown = true; + + if (!this.policy) { + return; + } + + if ( + this.hasPermission("storage") && + lazy.ExtensionStorageIDB.selectedBackendPromises.has(this) + ) { + this.state = "Shutdown: Storage"; + + // Wait the data migration to complete. + try { + await lazy.ExtensionStorageIDB.selectedBackendPromises.get(this); + } catch (err) { + Cu.reportError( + `Error while waiting for extension data migration on shutdown: ${this.policy.debugName} - ${err.message}::${err.stack}` + ); + } + this.state = "Shutdown: Storage complete"; + } + + if (this.rootURI instanceof Ci.nsIJARURI) { + this.state = "Shutdown: Flush jar cache"; + let file = this.rootURI.JARFile.QueryInterface(Ci.nsIFileURL).file; + Services.ppmm.broadcastAsyncMessage("Extension:FlushJarCache", { + path: file.path, + }); + this.state = "Shutdown: Flushed jar cache"; + } + + const isAppShutdown = reason === "APP_SHUTDOWN"; + if (this.cleanupFile || !isAppShutdown) { + StartupCache.clearAddonData(this.id); + } + + activeExtensionIDs.delete(this.id); + sharedData.set("extensions/activeIDs", activeExtensionIDs); + + for (let key of this.sharedDataKeys) { + sharedData.delete(key); + } + + Services.ppmm.removeMessageListener(this.MESSAGE_EMIT_EVENT, this); + + this.updatePermissions(reason); + + // The service worker registrations related to the extensions are unregistered + // only when the extension is not shutting down as part of the application + // shutdown (a previously registered service worker is expected to stay + // active across browser restarts), the service worker may have been + // registered through the manifest.json background.service_worker property + // or from an extension page through the service worker API if allowed + // through the about:config pref. + if (!isAppShutdown) { + this.state = "Shutdown: ServiceWorkers"; + // TODO: ServiceWorkerCleanUp may go away once Bug 1183245 is fixed. + await lazy.ServiceWorkerCleanUp.removeFromPrincipal(this.principal); + this.state = "Shutdown: ServiceWorkers completed"; + } + + if (!this.manifest) { + this.state = "Shutdown: Complete: No manifest"; + this.policy.active = false; + + return this.cleanupGeneratedFile(); + } + + GlobalManager.uninit(this); + + for (let obj of this.onShutdown) { + obj.close(); + } + + ParentAPIManager.shutdownExtension(this.id, reason); + + Management.emit("shutdown", this); + this.emit("shutdown", isAppShutdown); + + const TIMED_OUT = Symbol(); + + this.state = "Shutdown: Emit shutdown"; + let result = await Promise.race([ + this.broadcast("Extension:Shutdown", { id: this.id }), + promiseTimeout(CHILD_SHUTDOWN_TIMEOUT_MS).then(() => TIMED_OUT), + ]); + this.state = `Shutdown: Emitted shutdown: ${result === TIMED_OUT}`; + if (result === TIMED_OUT) { + Cu.reportError( + `Timeout while waiting for extension child to shutdown: ${this.policy.debugName}` + ); + } + + this.policy.active = false; + + this.state = `Shutdown: Complete (${this.cleanupFile})`; + return this.cleanupGeneratedFile(); + } + + observe(subject, topic, data) { + if (topic === "xpcom-shutdown") { + this.cleanupGeneratedFile(); + } + } + + get name() { + return this.manifest.name; + } + + get optionalOrigins() { + if (this._optionalOrigins == null) { + let { origins } = this.manifestOptionalPermissions; + this._optionalOrigins = new MatchPatternSet(origins, { + restrictSchemes: this.restrictSchemes, + ignorePath: true, + }); + } + return this._optionalOrigins; + } + + get hasBrowserActionUI() { + return this.manifest.browser_action || this.manifest.action; + } + + getPreferredIcon(size = 16) { + return IconDetails.getPreferredIcon(this.manifest.icons ?? {}, this, size) + .icon; + } +} + +export class Dictionary extends ExtensionData { + constructor(addonData, startupReason) { + super(addonData.resourceURI); + this.id = addonData.id; + this.startupData = addonData.startupData; + } + + static getBootstrapScope() { + return new DictionaryBootstrapScope(); + } + + async startup(reason) { + this.dictionaries = {}; + for (let [lang, path] of Object.entries(this.startupData.dictionaries)) { + let uri = Services.io.newURI( + path.slice(0, -4) + ".aff", + null, + this.rootURI + ); + this.dictionaries[lang] = uri; + + lazy.spellCheck.addDictionary(lang, uri); + } + + Management.emit("ready", this); + } + + async shutdown(reason) { + if (reason !== "APP_SHUTDOWN") { + lazy.AddonManagerPrivate.unregisterDictionaries(this.dictionaries); + } + } +} + +export class Langpack extends ExtensionData { + constructor(addonData, startupReason) { + super(addonData.resourceURI); + this.startupData = addonData.startupData; + this.manifestCacheKey = [addonData.id, addonData.version]; + } + + static getBootstrapScope() { + return new LangpackBootstrapScope(); + } + + async promiseLocales(locale) { + let locales = await StartupCache.locales.get( + [this.id, "@@all_locales"], + () => this._promiseLocaleMap() + ); + + return this._setupLocaleData(locales); + } + + parseManifest() { + return StartupCache.manifests.get(this.manifestCacheKey, () => + super.parseManifest() + ); + } + + async startup(reason) { + this.chromeRegistryHandle = null; + if (this.startupData.chromeEntries.length) { + const manifestURI = Services.io.newURI( + "manifest.json", + null, + this.rootURI + ); + this.chromeRegistryHandle = lazy.aomStartup.registerChrome( + manifestURI, + this.startupData.chromeEntries + ); + } + + const langpackId = this.startupData.langpackId; + const l10nRegistrySources = this.startupData.l10nRegistrySources; + + lazy.resourceProtocol.setSubstitution(langpackId, this.rootURI); + + const fileSources = Object.entries(l10nRegistrySources).map(entry => { + const [sourceName, basePath] = entry; + return new L10nFileSource( + `${sourceName}-${langpackId}`, + langpackId, + this.startupData.languages, + `resource://${langpackId}/${basePath}localization/{locale}/` + ); + }); + + L10nRegistry.getInstance().registerSources(fileSources); + + Services.obs.notifyObservers( + { wrappedJSObject: { langpack: this } }, + "webextension-langpack-startup" + ); + } + + async shutdown(reason) { + if (reason === "APP_SHUTDOWN") { + // If we're shutting down, let's not bother updating the state of each + // system. + return; + } + + const sourcesToRemove = Object.keys( + this.startupData.l10nRegistrySources + ).map(sourceName => `${sourceName}-${this.startupData.langpackId}`); + L10nRegistry.getInstance().removeSources(sourcesToRemove); + + if (this.chromeRegistryHandle) { + this.chromeRegistryHandle.destruct(); + this.chromeRegistryHandle = null; + } + + lazy.resourceProtocol.setSubstitution(this.startupData.langpackId, null); + } +} + +// TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed. +export class SitePermission extends ExtensionData { + constructor(addonData, startupReason) { + super(addonData.resourceURI); + this.id = addonData.id; + this.hasShutdown = false; + } + + async loadManifest() { + let [manifestData] = await Promise.all([this.parseManifest()]); + + if (!manifestData) { + return; + } + + this.manifest = manifestData.manifest; + this.type = manifestData.type; + this.sitePermissions = this.manifest.site_permissions; + // 1 install_origins is mandatory for this addon type + this.siteOrigin = this.manifest.install_origins[0]; + + return this.manifest; + } + + static getBootstrapScope() { + return new SitePermissionBootstrapScope(); + } + + // Array of principals that may be set by the addon. + getSupportedPrincipals() { + if (!this.siteOrigin) { + return []; + } + const uri = Services.io.newURI(this.siteOrigin); + return [ + Services.scriptSecurityManager.createContentPrincipal(uri, {}), + Services.scriptSecurityManager.createContentPrincipal(uri, { + privateBrowsingId: 1, + }), + ]; + } + + async startup(reason) { + await this.loadManifest(); + + this.ensureNoErrors(); + + let site_permissions = await lazy.SCHEMA_SITE_PERMISSIONS; + let perms = await lazy.ExtensionPermissions.get(this.id); + + if (this.hasShutdown) { + // Startup was interrupted and shutdown() has taken care of unloading + // the extension and running cleanup logic. + return; + } + + let privateAllowed = perms.permissions.includes(PRIVATE_ALLOWED_PERMISSION); + let principals = this.getSupportedPrincipals(); + + // Remove any permissions not contained in site_permissions + for (let principal of principals) { + let existing = Services.perms.getAllForPrincipal(principal); + for (let perm of existing) { + if ( + site_permissions.includes(perm) && + !this.sitePermissions.includes(perm) + ) { + Services.perms.removeFromPrincipal(principal, perm.type); + } + } + } + + // Ensure all permissions in site_permissions have been set, but do not + // overwrite the permission so the user can override the values in preferences. + for (let perm of this.sitePermissions) { + for (let principal of principals) { + let permission = Services.perms.testExactPermissionFromPrincipal( + principal, + perm + ); + if (permission == Ci.nsIPermissionManager.UNKNOWN_ACTION) { + let { privateBrowsingId } = principal.originAttributes; + let allow = privateBrowsingId == 0 || privateAllowed; + Services.perms.addFromPrincipal( + principal, + perm, + allow ? Services.perms.ALLOW_ACTION : Services.perms.DENY_ACTION, + Services.perms.EXPIRE_NEVER + ); + } + } + } + + Services.obs.notifyObservers( + { wrappedJSObject: { sitepermissions: this } }, + "webextension-sitepermissions-startup" + ); + } + + async shutdown(reason) { + this.hasShutdown = true; + // Permissions are retained across restarts + if (reason == "APP_SHUTDOWN") { + return; + } + let principals = this.getSupportedPrincipals(); + + for (let perm of this.sitePermissions || []) { + for (let principal of principals) { + Services.perms.removeFromPrincipal(principal, perm); + } + } + } +} + +// Exported for testing purposes. +export { ExtensionAddonObserver, PRIVILEGED_PERMS }; diff --git a/toolkit/components/extensions/ExtensionActions.sys.mjs b/toolkit/components/extensions/ExtensionActions.sys.mjs new file mode 100644 index 0000000000..8e8cf3abd2 --- /dev/null +++ b/toolkit/components/extensions/ExtensionActions.sys.mjs @@ -0,0 +1,667 @@ +/* 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/. */ + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { ExtensionError } = ExtensionUtils; + +import { ExtensionParent } from "resource://gre/modules/ExtensionParent.sys.mjs"; + +const { IconDetails, StartupCache } = ExtensionParent; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "MV2_ACTION_POPURL_RESTRICTED", + "extensions.manifestV2.actionsPopupURLRestricted", + false +); + +function parseColor(color, kind) { + if (typeof color == "string") { + let rgba = InspectorUtils.colorToRGBA(color); + if (!rgba) { + throw new ExtensionError(`Invalid badge ${kind} color: "${color}"`); + } + color = [rgba.r, rgba.g, rgba.b, Math.round(rgba.a * 255)]; + } + return color; +} + +/** Common base class for Page and Browser actions. */ +class PanelActionBase { + constructor(options, tabContext, extension) { + this.tabContext = tabContext; + this.extension = extension; + + // These are always defined on the action + this.defaults = { + enabled: true, + title: options.default_title || extension.name, + popup: options.default_popup || "", + icon: null, + }; + this.globals = Object.create(this.defaults); + + // eslint-disable-next-line mozilla/balanced-listeners + this.tabContext.on("location-change", this.handleLocationChange.bind(this)); + + // eslint-disable-next-line mozilla/balanced-listeners + this.tabContext.on("tab-select", (evt, tab) => { + this.updateOnChange(tab); + }); + + // eslint-disable-next-line mozilla/balanced-listeners + extension.on("add-permissions", () => this.updateOnChange()); + // eslint-disable-next-line mozilla/balanced-listeners + extension.on("remove-permissions", () => this.updateOnChange()); + // eslint-disable-next-line mozilla/balanced-listeners + extension.on("update-ignore-quarantine", () => this.updateOnChange()); + + // When preloading a popup we temporarily grant active tab permissions to + // the preloaded popup. If we don't end up opening we need to clear this + // permission when clearing the popup. + this.activeTabForPreload = null; + } + + onShutdown() { + this.tabContext.shutdown(); + } + + setPropertyFromDetails(details, prop, value) { + return this.setProperty(this.getTargetFromDetails(details), prop, value); + } + + /** + * Set a global, window specific or tab specific property. + * + * @param {XULElement|ChromeWindow|null} target + * A XULElement tab, a ChromeWindow, or null for the global data. + * @param {string} prop + * String property to set. Should should be one of "icon", "title", "badgeText", + * "popup", "badgeBackgroundColor", "badgeTextColor" or "enabled". + * @param {string} value + * Value for prop. + * @returns {object} + * The object to which the property has been set. + */ + setProperty(target, prop, value) { + let values = this.getContextData(target); + if (value === null) { + delete values[prop]; + } else { + values[prop] = value; + } + + this.updateOnChange(target); + return values; + } + + /** + * Gets the data associated with a tab, window, or the global one. + * + * @param {XULElement|ChromeWindow|null} target + * A XULElement tab, a ChromeWindow, or null for the global data. + * @returns {object} + * The icon, title, badge, etc. associated with the target. + */ + getContextData(target) { + if (target) { + return this.tabContext.get(target); + } + return this.globals; + } + + /** + * Retrieve the value of a global, window specific or tab specific property. + * + * @param {XULElement|ChromeWindow|null} target + * A XULElement tab, a ChromeWindow, or null for the global data. + * @param {string} prop + * Name of property to retrieve. Should should be one of "icon", + * "title", "badgeText", "popup", "badgeBackgroundColor" or "enabled". + * @returns {any} value + * Value of prop. + */ + getProperty(target, prop) { + return this.getContextData(target)[prop]; + } + + getPropertyFromDetails(details, prop) { + return this.getProperty(this.getTargetFromDetails(details), prop); + } + + enable(tabId) { + this.setPropertyFromDetails({ tabId }, "enabled", true); + } + + disable(tabId) { + this.setPropertyFromDetails({ tabId }, "enabled", false); + } + + getIcon(details = {}) { + return this.getPropertyFromDetails(details, "icon"); + } + + normalizeIcon(details, extension, context) { + let icon = IconDetails.normalize(details, extension, context); + if (!Object.keys(icon).length) { + return null; + } + return icon; + } + + /** + * Updates the `tabData` for any location change, however it only updates the button + * when the selected tab has a location change, or the selected tab has changed. + * + * @param {string} eventType + * The type of the event, should be "location-change". + * @param {XULElement} tab + * The tab whose location changed, or which has become selected. + * @param {boolean} [fromBrowse] + * - `true` if navigation occurred in `tab`. + * - `false` if the location changed but no navigation occurred, e.g. due to + a hash change or `history.pushState`. + * - Omitted if TabSelect has occurred, tabData does not need to be updated. + */ + handleLocationChange(eventType, tab, fromBrowse) { + if (fromBrowse) { + this.tabContext.clear(tab); + } + } + + /** + * Gets the popup url for a given tab. + * + * @param {XULElement} tab + * The tab the popup refers to. + * @param {boolean} strict + * If errors should be thrown if a URL is not available. + * @returns {string} + * The popup URL if a popup is present, undefined otherwise. + */ + getPopupUrl(tab, strict = false) { + if (!this.isShownForTab(tab)) { + if (strict) { + throw new ExtensionError("Popup is disabled"); + } + + return undefined; + } + let popupUrl = this.getProperty(tab, "popup"); + + if (strict && !popupUrl) { + throw new ExtensionError("No popup URL is set"); + } + + return popupUrl; + } + + /** + * Grants activeTab permission for a tab when preloading the popup. + * + * Will clear any existing activeTab permissions previously granted for any + * other tab. + * + * @param {XULElement} tab + * The tab that should be granted activeTab permission for. Set to + * null to clear previously granted activeTab permission. + */ + setActiveTabForPreload(tab = null) { + let oldTab = this.activeTabForPreload; + if (oldTab === tab) { + return; + } + this.activeTabForPreload = tab; + if (tab) { + this.extension.tabManager.addActiveTabPermission(tab); + } + if (oldTab) { + this.extension.tabManager.revokeActiveTabPermission(oldTab); + } + } + + /** + * Triggers this action and sends the appropriate event if needed. + * + * @param {XULElement} tab + * The tab on which the action was fired. + * @param {object} clickInfo + * Extra data passed to the second parameter to the action API's + * onClicked event. + * @returns {string} + * the popup URL if a popup should be open, undefined otherwise. + */ + triggerClickOrPopup(tab, clickInfo = undefined) { + if (!this.isShownForTab(tab)) { + return null; + } + + // Now that the action is actually being triggered we can clear any + // existing preloaded activeTab permission. + this.setActiveTabForPreload(null); + this.extension.tabManager.addActiveTabPermission(tab); + this.extension.tabManager.activateScripts(tab); + + let popupUrl = this.getProperty(tab, "popup"); + // The "click" event is only dispatched when the popup is not shown. This + // is done for compatibility with the Google Chrome onClicked extension + // API. + if (!popupUrl) { + this.dispatchClick(tab, clickInfo); + } + this.updateOnChange(tab); + return popupUrl; + } + + api(context) { + let { extension } = context; + return { + setTitle: details => { + this.setPropertyFromDetails(details, "title", details.title); + }, + getTitle: details => { + return this.getPropertyFromDetails(details, "title"); + }, + setIcon: details => { + details.iconType = "browserAction"; + this.setPropertyFromDetails( + details, + "icon", + this.normalizeIcon(details, extension, context) + ); + }, + setPopup: details => { + // Note: Chrome resolves arguments to setIcon relative to the calling + // context, but resolves arguments to setPopup relative to the extension + // root. + // For internal consistency, we currently resolve both relative to the + // calling context. + let url = details.popup && context.uri.resolve(details.popup); + + if (url && !context.checkLoadURL(url)) { + return Promise.reject({ message: `Access denied for URL ${url}` }); + } + + // On manifest_version 3 is mandatory for the resolved URI to belong to the + // current extension (see Bug 1760608). + // + // The same restriction is extended extend to MV2 extensions if the + // "extensions.manifestV2.actionsPopupURLRestricted" preference is set to true. + // + // (Currently set to true by default on GeckoView builds, where the set of + // extensions supported is limited to a small set and so less risks of + // unexpected regressions for the existing extensions). + if ( + url && + !url.startsWith(extension.baseURI.spec) && + (context.extension.manifestVersion >= 3 || + lazy.MV2_ACTION_POPURL_RESTRICTED) + ) { + return Promise.reject({ message: `Access denied for URL ${url}` }); + } + + this.setPropertyFromDetails(details, "popup", url); + }, + getPopup: details => { + return this.getPropertyFromDetails(details, "popup"); + }, + }; + } + + // Override these + + /** + * Update the toolbar button when the extension changes the icon, title, url, etc. + * If it only changes a parameter for a single tab, `target` will be that tab. + * If it only changes a parameter for a single window, `target` will be that window. + * Otherwise `target` will be null. + * + * @param {XULElement|ChromeWindow|null} target + * Browser tab or browser chrome window, may be null. + */ + updateOnChange(target) {} + + /** + * Get tab object from tabId. + * + * @param {string} tabId + * Internal id of the tab to get. + */ + getTab(tabId) {} + + /** + * Get window object from windowId + * + * @param {string} windowId + * Internal id of the window to get. + */ + getWindow(windowId) {} + + /** + * Gets the target object corresponding to the `details` parameter of the various + * get* and set* API methods. + * + * @param {object} details + * An object with optional `tabId` or `windowId` properties. + * @param {number} [details.tabId] + * @param {number} [details.windowId] + * @throws if both `tabId` and `windowId` are specified, or if they are invalid. + * @returns {XULElement|ChromeWindow|null} + * If a `tabId` was specified, the corresponding XULElement tab. + * If a `windowId` was specified, the corresponding ChromeWindow. + * Otherwise, `null`. + */ + getTargetFromDetails({ tabId, windowId }) { + return null; + } + + /** + * Triggers a click event. + * + * @param {XULElement} tab + * The tab where this event should be fired. + * @param {object} clickInfo + * Extra data passed to the second parameter to the action API's + * onClicked event. + */ + dispatchClick(tab, clickInfo) {} + + /** + * Checks whether this action is shown. + * + * @param {XULElement} tab + * The tab to be checked + * @returns {boolean} + */ + isShownForTab(tab) { + return false; + } +} + +export class PageActionBase extends PanelActionBase { + constructor(tabContext, extension) { + const options = extension.manifest.page_action; + super(options, tabContext, extension); + + // `enabled` can have three different values: + // - `false`. This means the page action is not shown. + // It's set as default if show_matches is empty. Can also be set in a tab via + // `pageAction.hide(tabId)`, e.g. in order to override show_matches. + // - `true`. This means the page action is shown. + // It's never set as default because doesn't really match all URLs + // (e.g. "about:" URLs). But can be set in a tab via `pageAction.show(tabId)`. + // - `undefined`. + // This is the default value when there are some patterns in show_matches. + // Can't be set as a tab-specific value. + let enabled, showMatches, hideMatches; + let show_matches = options.show_matches || []; + let hide_matches = options.hide_matches || []; + if (!show_matches.length) { + // Always hide by default. No need to do any pattern matching. + enabled = false; + } else { + // Might show or hide depending on the URL. Enable pattern matching. + const { restrictSchemes } = extension; + showMatches = new MatchPatternSet(show_matches, { restrictSchemes }); + hideMatches = new MatchPatternSet(hide_matches, { restrictSchemes }); + } + + this.defaults = { + ...this.defaults, + enabled, + showMatches, + hideMatches, + pinned: options.pinned, + }; + this.globals = Object.create(this.defaults); + } + + handleLocationChange(eventType, tab, fromBrowse) { + super.handleLocationChange(eventType, tab, fromBrowse); + if (fromBrowse === false) { + // Clear pattern matching cache when URL changes. + let tabData = this.tabContext.get(tab); + if (tabData.patternMatching !== undefined) { + tabData.patternMatching = undefined; + } + } + + if (tab.selected) { + // isShownForTab will do pattern matching (if necessary) and store the result + // so that updateButton knows whether the page action should be shown. + this.isShownForTab(tab); + this.updateOnChange(tab); + } + } + + // Checks whether the tab action is shown when the specified tab becomes active. + // Does pattern matching if necessary, and caches the result as a tab-specific value. + // @param {XULElement} tab + // The tab to be checked + // @return boolean + isShownForTab(tab) { + let tabData = this.getContextData(tab); + + // If there is a "show" value, return it. Can be due to show(), hide() or empty show_matches. + if (tabData.enabled !== undefined) { + return tabData.enabled; + } + + // Otherwise pattern matching must have been configured. Do it, caching the result. + if (tabData.patternMatching === undefined) { + let uri = tab.linkedBrowser.currentURI; + tabData.patternMatching = + tabData.showMatches.matches(uri) && !tabData.hideMatches.matches(uri); + } + return tabData.patternMatching; + } + + async loadIconData() { + const { extension } = this; + const options = extension.manifest.page_action; + this.defaults.icon = await StartupCache.get( + extension, + ["pageAction", "default_icon"], + () => + this.normalizeIcon( + { path: options.default_icon || "" }, + extension, + null + ) + ); + } + + getPinned() { + return this.globals.pinned; + } + + getTargetFromDetails({ tabId, windowId }) { + // PageActionBase doesn't support |windowId| + if (tabId != null) { + return this.getTab(tabId); + } + return null; + } + + api(context) { + return { + ...super.api(context), + show: (...args) => this.enable(...args), + hide: (...args) => this.disable(...args), + isShown: ({ tabId }) => { + let tab = this.getTab(tabId); + return this.isShownForTab(tab); + }, + }; + } +} + +export class BrowserActionBase extends PanelActionBase { + constructor(tabContext, extension) { + const options = + extension.manifest.browser_action || extension.manifest.action; + super(options, tabContext, extension); + + let default_area = + Services.policies?.getExtensionSettings(extension.id)?.default_area || + options.default_area || + "menupanel"; + + this.defaults = { + ...this.defaults, + badgeText: "", + badgeBackgroundColor: [0xd9, 0, 0, 255], + badgeDefaultColor: [255, 255, 255, 255], + badgeTextColor: null, + default_area, + }; + this.globals = Object.create(this.defaults); + } + + async loadIconData() { + const { extension } = this; + const options = + extension.manifest.browser_action || extension.manifest.action; + this.defaults.icon = await StartupCache.get( + extension, + ["browserAction", "default_icon"], + () => + IconDetails.normalize( + { + path: options.default_icon || extension.manifest.icons, + iconType: "browserAction", + themeIcons: options.theme_icons, + }, + extension + ) + ); + } + + handleLocationChange(eventType, tab, fromBrowse) { + super.handleLocationChange(eventType, tab, fromBrowse); + if (fromBrowse) { + this.updateOnChange(tab); + } + } + + getTargetFromDetails({ tabId, windowId }) { + if (tabId != null && windowId != null) { + throw new ExtensionError( + "Only one of tabId and windowId can be specified." + ); + } + if (tabId != null) { + return this.getTab(tabId); + } else if (windowId != null) { + return this.getWindow(windowId); + } + return null; + } + + getDefaultArea() { + return this.globals.default_area; + } + + /** + * Determines the text badge color to be used in a tab, window, or globally. + * + * @param {object} values + * The values associated with the tab or window, or global values. + * @returns {ColorArray} + */ + getTextColor(values) { + // If a text color has been explicitly provided, use it. + let { badgeTextColor } = values; + if (badgeTextColor) { + return badgeTextColor; + } + + // Otherwise, check if the default color to be used has been cached previously. + let { badgeDefaultColor } = values; + if (badgeDefaultColor) { + return badgeDefaultColor; + } + + // Choose a color among white and black, maximizing contrast with background + // according to https://www.w3.org/TR/WCAG20-TECHS/G18.html#G18-procedure + let [r, g, b] = values.badgeBackgroundColor + .slice(0, 3) + .map(function (channel) { + channel /= 255; + if (channel <= 0.03928) { + return channel / 12.92; + } + return ((channel + 0.055) / 1.055) ** 2.4; + }); + let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b; + + // The luminance is 0 for black, 1 for white, and `lum` for the background color. + // Since `0 <= lum`, the contrast ratio for black is `c0 = (lum + 0.05) / 0.05`. + // Since `lum <= 1`, the contrast ratio for white is `c1 = 1.05 / (lum + 0.05)`. + // We want to maximize contrast, so black is chosen if `c1 < c0`, that is, if + // `1.05 * 0.05 < (L + 0.05) ** 2`. Otherwise white is chosen. + let channel = 1.05 * 0.05 < (lum + 0.05) ** 2 ? 0 : 255; + let result = [channel, channel, channel, 255]; + + // Cache the result as high as possible in the prototype chain + while (!Object.getOwnPropertyDescriptor(values, "badgeDefaultColor")) { + values = Object.getPrototypeOf(values); + } + values.badgeDefaultColor = result; + return result; + } + + isShownForTab(tab) { + return this.getProperty(tab, "enabled"); + } + + api(context) { + return { + ...super.api(context), + enable: (...args) => this.enable(...args), + disable: (...args) => this.disable(...args), + isEnabled: details => { + return this.getPropertyFromDetails(details, "enabled"); + }, + setBadgeText: details => { + this.setPropertyFromDetails(details, "badgeText", details.text); + }, + getBadgeText: details => { + return this.getPropertyFromDetails(details, "badgeText"); + }, + setBadgeBackgroundColor: details => { + let color = parseColor(details.color, "background"); + let values = this.setPropertyFromDetails( + details, + "badgeBackgroundColor", + color + ); + if (color === null) { + // Let the default text color inherit after removing background color + delete values.badgeDefaultColor; + } else { + // Invalidate a cached default color calculated with the old background + values.badgeDefaultColor = null; + } + }, + getBadgeBackgroundColor: details => { + return this.getPropertyFromDetails(details, "badgeBackgroundColor"); + }, + setBadgeTextColor: details => { + let color = parseColor(details.color, "text"); + this.setPropertyFromDetails(details, "badgeTextColor", color); + }, + getBadgeTextColor: details => { + let target = this.getTargetFromDetails(details); + let values = this.getContextData(target); + return this.getTextColor(values); + }, + }; + } +} diff --git a/toolkit/components/extensions/ExtensionActivityLog.sys.mjs b/toolkit/components/extensions/ExtensionActivityLog.sys.mjs new file mode 100644 index 0000000000..dd0fd695a1 --- /dev/null +++ b/toolkit/components/extensions/ExtensionActivityLog.sys.mjs @@ -0,0 +1,118 @@ +/* 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/. */ + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", +}); +ChromeUtils.defineLazyGetter(lazy, "tabTracker", () => { + return lazy.ExtensionParent.apiManager.global.tabTracker; +}); + +var { DefaultMap } = ExtensionUtils; + +const MSG_SET_ENABLED = "Extension:ActivityLog:SetEnabled"; +const MSG_LOG = "Extension:ActivityLog:DoLog"; + +export const ExtensionActivityLog = { + initialized: false, + + // id => Set(callbacks) + listeners: new DefaultMap(() => new Set()), + watchedIds: new Set(), + + init() { + if (this.initialized) { + return; + } + + this.initialized = true; + + Services.ppmm.sharedData.set("extensions/logging", this.watchedIds); + + Services.ppmm.addMessageListener(MSG_LOG, this); + }, + + /** + * Notify all listeners of an extension activity. + * + * @param {string} id The ID of the extension that caused the activity. + * @param {string} viewType The view type the activity is in. + * @param {string} type The type of the activity. + * @param {string} name The API name or path. + * @param {object} data Activity specific data. + * @param {Date} [timeStamp] The timestamp for the activity. + */ + log(id, viewType, type, name, data, timeStamp) { + if (!this.initialized) { + return; + } + let callbacks = this.listeners.get(id); + if (callbacks) { + if (!timeStamp) { + timeStamp = new Date(); + } + + for (let callback of callbacks) { + try { + callback({ id, viewType, timeStamp, type, name, data }); + } catch (e) { + Cu.reportError(e); + } + } + } + }, + + addListener(id, callback) { + this.init(); + let callbacks = this.listeners.get(id); + if (callbacks.size === 0) { + this.watchedIds.add(id); + Services.ppmm.sharedData.set("extensions/logging", this.watchedIds); + Services.ppmm.sharedData.flush(); + Services.ppmm.broadcastAsyncMessage(MSG_SET_ENABLED, { id, value: true }); + } + callbacks.add(callback); + }, + + removeListener(id, callback) { + let callbacks = this.listeners.get(id); + if (callbacks.size > 0) { + callbacks.delete(callback); + if (callbacks.size === 0) { + this.watchedIds.delete(id); + Services.ppmm.sharedData.set("extensions/logging", this.watchedIds); + Services.ppmm.sharedData.flush(); + Services.ppmm.broadcastAsyncMessage(MSG_SET_ENABLED, { + id, + value: false, + }); + } + } + }, + + receiveMessage({ name, data }) { + if (name === MSG_LOG) { + let { viewType, browsingContextId } = data; + if (browsingContextId && (!viewType || viewType == "tab")) { + let browser = + BrowsingContext.get(browsingContextId).top.embedderElement; + let browserData = lazy.tabTracker.getBrowserData(browser); + if (browserData && browserData.tabId !== undefined) { + data.data.tabId = browserData.tabId; + } + } + this.log( + data.id, + data.viewType, + data.type, + data.name, + data.data, + new Date(data.timeStamp) + ); + } + }, +}; diff --git a/toolkit/components/extensions/ExtensionChild.sys.mjs b/toolkit/components/extensions/ExtensionChild.sys.mjs new file mode 100644 index 0000000000..20c3c8f2ab --- /dev/null +++ b/toolkit/components/extensions/ExtensionChild.sys.mjs @@ -0,0 +1,1025 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +/** + * This file handles addon logic that is independent of the chrome process and + * may run in all web content and extension processes. + * + * Don't put contentscript logic here, use ExtensionContent.jsm instead. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "finalizationService", + "@mozilla.org/toolkit/finalizationwitness;1", + "nsIFinalizationWitnessService" +); + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionContent: "resource://gre/modules/ExtensionContent.sys.mjs", + ExtensionPageChild: "resource://gre/modules/ExtensionPageChild.sys.mjs", + ExtensionProcessScript: + "resource://gre/modules/ExtensionProcessScript.sys.mjs", + NativeApp: "resource://gre/modules/NativeMessaging.sys.mjs", +}); + +import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { DefaultMap, ExtensionError, LimitedSet, getUniqueId } = ExtensionUtils; + +const { + redefineGetter, + EventEmitter, + EventManager, + LocalAPIImplementation, + LocaleData, + NoCloneSpreadArgs, + SchemaAPIInterface, + withHandlingUserInput, +} = ExtensionCommon; + +const { sharedData } = Services.cpmm; + +const MSG_SET_ENABLED = "Extension:ActivityLog:SetEnabled"; +const MSG_LOG = "Extension:ActivityLog:DoLog"; + +export const ExtensionActivityLogChild = { + _initialized: false, + enabledExtensions: new Set(), + + init() { + if (this._initialized) { + return; + } + this._initialized = true; + + Services.cpmm.addMessageListener(MSG_SET_ENABLED, this); + + this.enabledExtensions = new Set( + Services.cpmm.sharedData.get("extensions/logging") + ); + }, + + receiveMessage({ name, data }) { + if (name === MSG_SET_ENABLED) { + if (data.value) { + this.enabledExtensions.add(data.id); + } else { + this.enabledExtensions.delete(data.id); + } + } + }, + + async log(context, type, name, data) { + this.init(); + let { id } = context.extension; + if (this.enabledExtensions.has(id)) { + this._sendActivity({ + timeStamp: Date.now(), + id, + viewType: context.viewType, + type, + name, + data, + browsingContextId: context.browsingContextId, + }); + } + }, + + _sendActivity(data) { + Services.cpmm.sendAsyncMessage(MSG_LOG, data); + }, +}; + +// A helper to allow us to distinguish trusted errors from unsanitized errors. +// Extensions can create plain objects with arbitrary properties (such as +// mozWebExtLocation), but not create instances of ExtensionErrorHolder. +class ExtensionErrorHolder { + constructor(trustedErrorObject) { + this.trustedErrorObject = trustedErrorObject; + } +} + +/** + * A finalization witness helper that wraps a sendMessage response and + * guarantees to either get the promise resolved, or rejected when the + * wrapped promise goes out of scope. + */ +const StrongPromise = { + stillAlive: new Map(), + + wrap(promise, location) { + let id = String(getUniqueId()); + let witness = lazy.finalizationService.make( + "extensions-onMessage-witness", + id + ); + + return new Promise((resolve, reject) => { + this.stillAlive.set(id, { reject, location }); + promise.then(resolve, reject).finally(() => { + this.stillAlive.delete(id); + witness.forget(); + }); + }); + }, + + observe(subject, topic, id) { + let message = "Promised response from onMessage listener went out of scope"; + let { reject, location } = this.stillAlive.get(id); + reject(new ExtensionErrorHolder({ message, mozWebExtLocation: location })); + this.stillAlive.delete(id); + }, +}; +Services.obs.addObserver(StrongPromise, "extensions-onMessage-witness"); + +// Simple single-event emitter-like helper, exposes the EventManager api. +class SimpleEventAPI extends EventManager { + constructor(context, name) { + let fires = new Set(); + let register = fire => { + fires.add(fire); + fire.location = context.getCaller(); + return () => fires.delete(fire); + }; + super({ context, name, register }); + this.fires = fires; + } + /** @returns {any} */ + emit(...args) { + return [...this.fires].map(fire => fire.asyncWithoutClone(...args)); + } +} + +// runtime.OnMessage event helper, handles custom async/sendResponse logic. +class MessageEvent extends SimpleEventAPI { + emit(holder, sender) { + if (!this.fires.size || !this.context.active) { + return { received: false }; + } + + sender = Cu.cloneInto(sender, this.context.cloneScope); + let message = holder.deserialize(this.context.cloneScope); + + let responses = [...this.fires] + .map(fire => this.wrapResponse(fire, message, sender)) + .filter(x => x !== undefined); + + return !responses.length + ? { received: true, response: false } + : Promise.race(responses).then( + value => ({ response: true, value }), + error => Promise.reject(this.unwrapOrSanitizeError(error)) + ); + } + + unwrapOrSanitizeError(error) { + if (error instanceof ExtensionErrorHolder) { + return error.trustedErrorObject; + } + // If not a wrapped error, sanitize it and convert to ExtensionError, so + // that context.normalizeError will use the error message. + return new ExtensionError(error?.message ?? "An unexpected error occurred"); + } + + wrapResponse(fire, message, sender) { + let response, sendResponse; + let promise = new Promise(resolve => { + sendResponse = Cu.exportFunction(value => { + resolve(value); + response = promise; + }, this.context.cloneScope); + }); + + let result; + try { + result = fire.raw(message, sender, sendResponse); + } catch (e) { + return Promise.reject(e); + } + if ( + result && + typeof result === "object" && + Cu.getClassName(result, true) === "Promise" && + this.context.principal.subsumes(Cu.getObjectPrincipal(result)) + ) { + return StrongPromise.wrap(result, fire.location); + } else if (result === true) { + return StrongPromise.wrap(promise, fire.location); + } + return response; + } +} + +function holdMessage(name, anonymizedName, data, native = null) { + if (native && AppConstants.platform !== "android") { + data = lazy.NativeApp.encodeMessage(native.context, data); + } + return new StructuredCloneHolder(name, anonymizedName, data); +} + +// Implements the runtime.Port extension API object. +class Port { + /** + * @param {BaseContext} context The context that owns this port. + * @param {number} portId Uniquely identifies this port's channel. + * @param {string} name Arbitrary port name as defined by the addon. + * @param {boolean} native Is this a Port for native messaging. + * @param {object} sender The `Port.sender` property. + */ + constructor(context, portId, name, native, sender) { + this.context = context; + this.name = name; + this.sender = sender; + this.holdMessage = native + ? (name, anonymizedName, data) => + holdMessage(name, anonymizedName, data, this) + : holdMessage; + this.conduit = context.openConduit(this, { + portId, + native, + source: !sender, + recv: ["PortMessage", "PortDisconnect"], + send: ["PortMessage"], + }); + this.initEventManagers(); + } + + initEventManagers() { + const { context } = this; + this.onMessage = new SimpleEventAPI(context, "Port.onMessage"); + this.onDisconnect = new SimpleEventAPI(context, "Port.onDisconnect"); + } + + getAPI() { + // Public Port object handed to extensions from `connect()` and `onConnect`. + return { + name: this.name, + sender: this.sender, + error: null, + onMessage: this.onMessage.api(), + onDisconnect: this.onDisconnect.api(), + postMessage: this.sendPortMessage.bind(this), + disconnect: () => this.conduit.close(), + }; + } + + recvPortMessage({ holder }) { + this.onMessage.emit(holder.deserialize(this.api), this.api); + } + + recvPortDisconnect({ error = null }) { + this.conduit.close(); + if (this.context.active) { + this.api.error = error && this.context.normalizeError(error); + this.onDisconnect.emit(this.api); + } + } + + sendPortMessage(json) { + if (this.conduit.actor) { + return this.conduit.sendPortMessage({ + holder: this.holdMessage( + `Port/${this.context.extension.id}/sendPortMessage/${this.name}`, + `Port/${this.context.extension.id}/sendPortMessage/`, + json + ), + }); + } + throw new this.context.Error("Attempt to postMessage on disconnected port"); + } + + get api() { + const scope = this.context.cloneScope; + const value = Cu.cloneInto(this.getAPI(), scope, { cloneFunctions: true }); + return redefineGetter(this, "api", value); + } +} + +/** + * Each extension context gets its own Messenger object. It handles the + * basics of sendMessage, onMessage, connect and onConnect. + */ +class Messenger { + constructor(context) { + this.context = context; + this.conduit = context.openConduit(this, { + childId: context.childManager.id, + query: ["NativeMessage", "RuntimeMessage", "PortConnect"], + recv: ["RuntimeMessage", "PortConnect"], + }); + this.initEventManagers(); + } + + initEventManagers() { + const { context } = this; + this.onConnect = new SimpleEventAPI(context, "runtime.onConnect"); + this.onConnectEx = new SimpleEventAPI(context, "runtime.onConnectExternal"); + this.onMessage = new MessageEvent(context, "runtime.onMessage"); + this.onMessageEx = new MessageEvent(context, "runtime.onMessageExternal"); + } + + sendNativeMessage(nativeApp, json) { + let holder = holdMessage( + `Messenger/${this.context.extension.id}/sendNativeMessage/${nativeApp}`, + null, + json, + this + ); + return this.conduit.queryNativeMessage({ nativeApp, holder }); + } + + sendRuntimeMessage({ extensionId, message, callback, ...args }) { + let response = this.conduit.queryRuntimeMessage({ + extensionId: extensionId || this.context.extension.id, + holder: holdMessage( + `Messenger/${this.context.extension.id}/sendRuntimeMessage`, + null, + message + ), + ...args, + }); + // If |response| is a rejected promise, the value will be sanitized by + // wrapPromise, according to the rules of context.normalizeError. + return this.context.wrapPromise(response, callback); + } + + connect({ name, native, ...args }) { + let portId = getUniqueId(); + let port = new Port(this.context, portId, name, !!native); + this.conduit + .queryPortConnect({ portId, name, native, ...args }) + .catch(error => port.recvPortDisconnect({ error })); + return port.api; + } + + recvPortConnect({ extensionId, portId, name, sender }) { + let event = sender.id === extensionId ? this.onConnect : this.onConnectEx; + if (this.context.active && event.fires.size) { + let port = new Port(this.context, portId, name, false, sender); + return event.emit(port.api).length; + } + } + + recvRuntimeMessage({ extensionId, holder, sender }) { + let event = sender.id === extensionId ? this.onMessage : this.onMessageEx; + return event.emit(holder, sender); + } +} + +// For test use only. +var ExtensionManager = { + extensions: new Map(), +}; + +// Represents a browser extension in the content process. +class BrowserExtensionContent extends EventEmitter { + constructor(policy) { + super(); + + this.policy = policy; + // Set a weak reference to this instance on the WebExtensionPolicy expando properties + // (because it makes it easier to reach the extension instance from the policy object + // without leaking it due to a circular dependency keeping it alive). + this.policy.weakExtension = Cu.getWeakReference(this); + + this.instanceId = policy.instanceId; + this.optionalPermissions = policy.optionalPermissions; + + if (WebExtensionPolicy.isExtensionProcess) { + // Keep in sync with serializeExtended in Extension.jsm + let ed = this.getSharedData("extendedData"); + this.backgroundScripts = ed.backgroundScripts; + this.backgroundWorkerScript = ed.backgroundWorkerScript; + this.childModules = ed.childModules; + this.dependencies = ed.dependencies; + this.persistentBackground = ed.persistentBackground; + this.schemaURLs = ed.schemaURLs; + } + + this.MESSAGE_EMIT_EVENT = `Extension:EmitEvent:${this.instanceId}`; + Services.cpmm.addMessageListener(this.MESSAGE_EMIT_EVENT, this); + + this.apiManager = this.getAPIManager(); + + this._manifest = null; + this._localeData = null; + + this.baseURI = Services.io.newURI(`moz-extension://${this.uuid}/`); + this.baseURL = this.baseURI.spec; + + this.principal = Services.scriptSecurityManager.createContentPrincipal( + this.baseURI, + {} + ); + + // Only used in addon processes. + this.blockedParsingDocuments = new WeakSet(); + this.views = new Set(); + + // Only used for devtools views. + this.devtoolsViews = new Set(); + + ExtensionManager.extensions.set(this.id, this); + } + + get id() { + return this.policy.id; + } + + get uuid() { + return this.policy.mozExtensionHostname; + } + + get permissions() { + return new Set(this.policy.permissions); + } + + get allowedOrigins() { + return this.policy.allowedOrigins; + } + + getSharedData(key, value) { + return sharedData.get(`extension/${this.id}/${key}`); + } + + get localeData() { + if (!this._localeData) { + this._localeData = new LocaleData(this.getSharedData("locales")); + } + return this._localeData; + } + + get manifest() { + if (!this._manifest) { + this._manifest = this.getSharedData("manifest"); + } + return this._manifest; + } + + get manifestVersion() { + return this.manifest.manifest_version; + } + + get privateBrowsingAllowed() { + return this.policy.privateBrowsingAllowed; + } + + canAccessWindow(window) { + return this.policy.canAccessWindow(window); + } + + getAPIManager() { + /** @type {InstanceType[]} */ + let apiManagers = [lazy.ExtensionPageChild.apiManager]; + + if (this.dependencies) { + for (let id of this.dependencies) { + let extension = lazy.ExtensionProcessScript.getExtensionChild(id); + if (extension) { + apiManagers.push(extension.experimentAPIManager); + } + } + } + + if (this.childModules) { + this.experimentAPIManager = new ExtensionCommon.LazyAPIManager( + "addon", + this.childModules, + this.schemaURLs + ); + + apiManagers.push(this.experimentAPIManager); + } + + if (apiManagers.length == 1) { + return apiManagers[0]; + } + + return new ExtensionCommon.MultiAPIManager("addon", apiManagers.reverse()); + } + + shutdown() { + ExtensionManager.extensions.delete(this.id); + lazy.ExtensionContent.shutdownExtension(this); + Services.cpmm.removeMessageListener(this.MESSAGE_EMIT_EVENT, this); + this.emit("shutdown"); + } + + getContext(window) { + return lazy.ExtensionContent.getContext(this, window); + } + + emit(event, ...args) { + Services.cpmm.sendAsyncMessage(this.MESSAGE_EMIT_EVENT, { event, args }); + return super.emit(event, ...args); + } + + // TODO(Bug 1768471): consider folding this back into emit if we will change it to + // return a value as EventEmitter and Extension emit methods do. + emitLocalWithResult(event, ...args) { + return super.emit(event, ...args); + } + + receiveMessage({ name, data }) { + if (name === this.MESSAGE_EMIT_EVENT) { + super.emit(data.event, ...data.args); + } + } + + localizeMessage(...args) { + return this.localeData.localizeMessage(...args); + } + + localize(...args) { + return this.localeData.localize(...args); + } + + hasPermission(perm) { + // If the permission is a "manifest property" permission, we check if the extension + // does have the required property in its manifest. + let manifest_ = "manifest:"; + if (perm.startsWith(manifest_)) { + // Handle nested "manifest property" permission (e.g. as in "manifest:property.nested"). + let value = this.manifest; + for (let prop of perm.substr(manifest_.length).split(".")) { + if (!value) { + break; + } + value = value[prop]; + } + + return value != null; + } + return this.permissions.has(perm); + } + + trackBlockedParsingDocument(doc) { + this.blockedParsingDocuments.add(doc); + } + + untrackBlockedParsingDocument(doc) { + this.blockedParsingDocuments.delete(doc); + } + + hasContextBlockedParsingDocument(extContext) { + return this.blockedParsingDocuments.has(extContext.contentWindow?.document); + } +} + +/** + * An object that runs an remote implementation of an API. + */ +class ProxyAPIImplementation extends SchemaAPIInterface { + /** + * @param {string} namespace The full path to the namespace that contains the + * `name` member. This may contain dots, e.g. "storage.local". + * @param {string} name The name of the method or property. + * @param {ChildAPIManager} childApiManager The owner of this implementation. + * @param {boolean} alreadyLogged Whether the child already logged the event. + */ + constructor(namespace, name, childApiManager, alreadyLogged = false) { + super(); + this.path = `${namespace}.${name}`; + this.childApiManager = childApiManager; + this.alreadyLogged = alreadyLogged; + } + + revoke() { + let map = this.childApiManager.listeners.get(this.path); + for (let listener of map.listeners.keys()) { + this.removeListener(listener); + } + + this.path = null; + this.childApiManager = null; + } + + callFunctionNoReturn(args) { + this.childApiManager.callParentFunctionNoReturn(this.path, args); + } + + callAsyncFunction(args, callback, requireUserInput) { + const context = this.childApiManager.context; + const isHandlingUserInput = + context.contentWindow?.windowUtils?.isHandlingUserInput; + if (requireUserInput) { + if (!isHandlingUserInput) { + let err = new context.cloneScope.Error( + `${this.path} may only be called from a user input handler` + ); + return context.wrapPromise(Promise.reject(err), callback); + } + } + return this.childApiManager.callParentAsyncFunction( + this.path, + args, + callback, + { + alreadyLogged: this.alreadyLogged, + isHandlingUserInput, + } + ); + } + + addListener(listener, args) { + let map = this.childApiManager.listeners.get(this.path); + + if (map.listeners.has(listener)) { + // TODO: Called with different args? + return; + } + + let id = getUniqueId(); + + map.ids.set(id, listener); + map.listeners.set(listener, id); + + this.childApiManager.conduit.sendAddListener({ + childId: this.childApiManager.id, + listenerId: id, + path: this.path, + args, + alreadyLogged: this.alreadyLogged, + }); + } + + removeListener(listener) { + let map = this.childApiManager.listeners.get(this.path); + + if (!map.listeners.has(listener)) { + return; + } + + let id = map.listeners.get(listener); + map.listeners.delete(listener); + map.ids.delete(id); + map.removedIds.add(id); + + this.childApiManager.conduit.sendRemoveListener({ + childId: this.childApiManager.id, + listenerId: id, + path: this.path, + alreadyLogged: this.alreadyLogged, + }); + } + + hasListener(listener) { + let map = this.childApiManager.listeners.get(this.path); + return map.listeners.has(listener); + } +} + +class ChildLocalAPIImplementation extends LocalAPIImplementation { + constructor(pathObj, namespace, name, childApiManager) { + super(pathObj, name, childApiManager.context); + this.childApiManagerId = childApiManager.id; + this.fullname = `${namespace}.${name}`; + } + + /** + * Call the given function and also log the call as appropriate + * (i.e., with activity logging and/or profiler markers) + * + * @param {Function} callable The actual implementation to invoke. + * @param {Array} args Arguments to the function call. + * @returns {any} The return result of callable. + */ + callAndLog(callable, args) { + this.context.logActivity("api_call", this.fullname, { args }); + let start = Cu.now(); + try { + return callable(); + } finally { + ChromeUtils.addProfilerMarker( + "ExtensionChild", + { startTime: start }, + `${this.context.extension.id}, api_call: ${this.fullname}` + ); + } + } + + callFunction(args) { + return this.callAndLog(() => super.callFunction(args), args); + } + + callFunctionNoReturn(args) { + return this.callAndLog(() => super.callFunctionNoReturn(args), args); + } + + callAsyncFunction(args, callback, requireUserInput) { + return this.callAndLog( + () => super.callAsyncFunction(args, callback, requireUserInput), + args + ); + } +} + +// We create one instance of this class for every extension context that +// needs to use remote APIs. It uses the the JSWindowActor and +// JSProcessActor Conduits actors (see ConduitsChild.jsm) to communicate +// with the ParentAPIManager singleton in ExtensionParent.jsm. +// It handles asynchronous function calls as well as event listeners. +class ChildAPIManager { + constructor(context, messageManager, localAPICan, contextData) { + this.context = context; + this.messageManager = messageManager; + this.url = contextData.url; + + // The root namespace of all locally implemented APIs. If an extension calls + // an API that does not exist in this object, then the implementation is + // delegated to the ParentAPIManager. + this.localApis = localAPICan.root; + this.apiCan = localAPICan; + this.schema = this.apiCan.apiManager.schema; + + this.id = `${context.extension.id}.${context.contextId}`; + + this.conduit = context.openConduit(this, { + childId: this.id, + send: [ + "CreateProxyContext", + "ContextLoaded", + "APICall", + "AddListener", + "RemoveListener", + ], + recv: ["CallResult", "RunListener", "StreamFilterSuspendCancel"], + }); + + this.conduit.sendCreateProxyContext({ + childId: this.id, + extensionId: context.extension.id, + principal: context.principal, + ...contextData, + }); + + this.listeners = new DefaultMap(() => ({ + ids: new Map(), + listeners: new Map(), + removedIds: new LimitedSet(10), + })); + + // Map[callId -> Deferred] + this.callPromises = new Map(); + + this.permissionsChangedCallbacks = new Set(); + this.updatePermissions = null; + if (this.context.extension.optionalPermissions.length) { + this.updatePermissions = () => { + for (let callback of this.permissionsChangedCallbacks) { + try { + callback(); + } catch (err) { + Cu.reportError(err); + } + } + }; + this.context.extension.on("update-permissions", this.updatePermissions); + } + } + + inject(obj) { + this.schema.inject(obj, this); + } + + recvCallResult(data) { + let deferred = this.callPromises.get(data.callId); + this.callPromises.delete(data.callId); + if ("error" in data) { + deferred.reject(data.error); + } else { + let result = data.result.deserialize(this.context.cloneScope); + + deferred.resolve(new NoCloneSpreadArgs(result)); + } + } + + recvRunListener(data) { + let map = this.listeners.get(data.path); + let listener = map.ids.get(data.listenerId); + + if (listener) { + if (!this.context.active) { + Services.console.logStringMessage( + `Ignored listener for inactive context at childId=${data.childId} path=${data.path} listenerId=${data.listenerId}\n` + ); + return; + } + + let args = data.args.deserialize(this.context.cloneScope); + let fire = () => this.context.applySafeWithoutClone(listener, args); + return Promise.resolve( + data.handlingUserInput + ? withHandlingUserInput(this.context.contentWindow, fire) + : fire() + ).then(result => { + if (result !== undefined) { + return new StructuredCloneHolder( + `ChildAPIManager/${this.context.extension.id}/${data.path}`, + null, + result, + this.context.cloneScope + ); + } + return result; + }); + } + if (!map.removedIds.has(data.listenerId)) { + Services.console.logStringMessage( + `Unknown listener at childId=${data.childId} path=${data.path} listenerId=${data.listenerId}\n` + ); + } + } + + async recvStreamFilterSuspendCancel() { + const promise = this.context.extension.emitLocalWithResult( + "internal:stream-filter-suspend-cancel" + ); + // if all listeners throws emitLocalWithResult returns undefined. + if (!promise) { + return false; + } + + return promise.then(results => + results.some(hasActiveStreamFilter => hasActiveStreamFilter === true) + ); + } + + /** + * Call a function in the parent process and ignores its return value. + * + * @param {string} path The full name of the method, e.g. "tabs.create". + * @param {Array} args The parameters for the function. + */ + callParentFunctionNoReturn(path, args) { + this.conduit.sendAPICall({ childId: this.id, path, args }); + } + + /** + * Calls a function in the parent process and returns its result + * asynchronously. + * + * @param {string} path The full name of the method, e.g. "tabs.create". + * @param {Array} args The parameters for the function. + * @param {callback} [callback] The callback to be called when the + * function completes. + * @param {object} [options] Extra options. + * @returns {Promise|undefined} Must be void if `callback` is set, and a + * promise otherwise. The promise is resolved when the function completes. + */ + callParentAsyncFunction(path, args, callback, options = {}) { + let callId = getUniqueId(); + let deferred = Promise.withResolvers(); + this.callPromises.set(callId, deferred); + + let { + // Any child api that calls into a parent function will have already + // logged the api_call. Flag it so the parent doesn't log again. + alreadyLogged = true, + // Propagating the isHAndlingUserInput flag to the API call handler + // executed on the parent process side. + isHandlingUserInput = false, + } = options; + + // TODO: conduit.queryAPICall() + this.conduit.sendAPICall({ + childId: this.id, + callId, + path, + args, + options: { alreadyLogged, isHandlingUserInput }, + }); + return this.context.wrapPromise(deferred.promise, callback); + } + + /** + * Create a proxy for an event in the parent process. The returned event + * object shares its internal state with other instances. For instance, if + * `removeListener` is used on a listener that was added on another object + * through `addListener`, then the event is unregistered. + * + * @param {string} path The full name of the event, e.g. "tabs.onCreated". + * @returns {object} An object with the addListener, removeListener and + * hasListener methods. See SchemaAPIInterface for documentation. + */ + getParentEvent(path) { + let parts = path.split("."); + + let name = parts.pop(); + let namespace = parts.join("."); + + let impl = new ProxyAPIImplementation(namespace, name, this, true); + return { + addListener: (listener, ...args) => impl.addListener(listener, args), + removeListener: listener => impl.removeListener(listener), + hasListener: listener => impl.hasListener(listener), + }; + } + + close() { + // Reports CONDUIT_CLOSED on the parent side. + this.conduit.close(); + + if (this.updatePermissions) { + this.context.extension.off("update-permissions", this.updatePermissions); + } + } + + get cloneScope() { + return this.context.cloneScope; + } + + get principal() { + return this.context.principal; + } + + get manifestVersion() { + return this.context.manifestVersion; + } + + shouldInject(namespace, name, allowedContexts) { + // Do not generate content script APIs, unless explicitly allowed. + if ( + this.context.envType === "content_child" && + !allowedContexts.includes("content") + ) { + return false; + } + + // Do not generate devtools APIs, unless explicitly allowed. + if ( + this.context.envType === "devtools_child" && + !allowedContexts.includes("devtools") + ) { + return false; + } + + // Do not generate devtools APIs, unless explicitly allowed. + if ( + this.context.envType !== "devtools_child" && + allowedContexts.includes("devtools_only") + ) { + return false; + } + + // Do not generate content_only APIs, unless explicitly allowed. + if ( + this.context.envType !== "content_child" && + allowedContexts.includes("content_only") + ) { + return false; + } + + return true; + } + + getImplementation(namespace, name) { + this.apiCan.findAPIPath(`${namespace}.${name}`); + let obj = this.apiCan.findAPIPath(namespace); + + if (obj && name in obj) { + return new ChildLocalAPIImplementation(obj, namespace, name, this); + } + + return this.getFallbackImplementation(namespace, name); + } + + getFallbackImplementation(namespace, name) { + // No local API found, defer implementation to the parent. + return new ProxyAPIImplementation(namespace, name, this); + } + + hasPermission(permission) { + return this.context.extension.hasPermission(permission); + } + + isPermissionRevokable(permission) { + return this.context.extension.optionalPermissions.includes(permission); + } + + setPermissionsChangedCallback(callback) { + this.permissionsChangedCallbacks.add(callback); + } +} + +export var ExtensionChild = { + BrowserExtensionContent, + ChildAPIManager, + ChildLocalAPIImplementation, + MessageEvent, + Messenger, + Port, + ProxyAPIImplementation, + SimpleEventAPI, +}; diff --git a/toolkit/components/extensions/ExtensionChildDevToolsUtils.sys.mjs b/toolkit/components/extensions/ExtensionChildDevToolsUtils.sys.mjs new file mode 100644 index 0000000000..084fe8f940 --- /dev/null +++ b/toolkit/components/extensions/ExtensionChildDevToolsUtils.sys.mjs @@ -0,0 +1,111 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +/** + * @file + * This module contains utilities for interacting with DevTools + * from the child process. + */ + +import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; + +// Create a variable to hold the cached ThemeChangeObserver which does not +// get created until a devtools context has been created. +let themeChangeObserver; + +/** + * An observer that watches for changes to the devtools theme and provides + * that information to the devtools.panels.themeName API property, as well as + * emits events for the devtools.panels.onThemeChanged event. It also caches + * the current value of devtools.themeName. + */ +class ThemeChangeObserver extends EventEmitter { + constructor(themeName, onDestroyed) { + super(); + this.themeName = themeName; + this.onDestroyed = onDestroyed; + this.contexts = new Set(); + + Services.cpmm.addMessageListener("Extension:DevToolsThemeChanged", this); + } + + addContext(context) { + if (this.contexts.has(context)) { + throw new Error( + "addContext on the ThemeChangeObserver was called more than once" + + " for the context." + ); + } + + context.callOnClose({ + close: () => this.onContextClosed(context), + }); + + this.contexts.add(context); + } + + onContextClosed(context) { + this.contexts.delete(context); + + if (this.contexts.size === 0) { + this.destroy(); + } + } + + onThemeChanged(themeName) { + // Update the cached themeName and emit an event for the API. + this.themeName = themeName; + this.emit("themeChanged", themeName); + } + + receiveMessage({ name, data }) { + if (name === "Extension:DevToolsThemeChanged") { + this.onThemeChanged(data.themeName); + } + } + + destroy() { + Services.cpmm.removeMessageListener("Extension:DevToolsThemeChanged", this); + this.onDestroyed(); + this.onDestroyed = null; + this.contexts.clear(); + this.contexts = null; + } +} + +export var ExtensionChildDevToolsUtils = { + /** + * Creates an cached instance of the ThemeChangeObserver class and + * initializes it with the current themeName. This cached instance is + * destroyed when all of the contexts added to it are closed. + * + * @param {string} themeName The name of the current devtools theme. + * @param {import("ExtensionPageChild.sys.mjs").DevToolsContextChild} context + * The newly created devtools page context. + */ + initThemeChangeObserver(themeName, context) { + if (!themeChangeObserver) { + themeChangeObserver = new ThemeChangeObserver(themeName, function () { + themeChangeObserver = null; + }); + } + themeChangeObserver.addContext(context); + }, + + /** + * Returns the cached instance of ThemeChangeObserver. + * + * @returns {ThemeChangeObserver} The cached instance of ThemeChangeObserver. + */ + getThemeChangeObserver() { + if (!themeChangeObserver) { + throw new Error( + "A ThemeChangeObserver must be created before being retrieved." + ); + } + return themeChangeObserver; + }, +}; diff --git a/toolkit/components/extensions/ExtensionCommon.sys.mjs b/toolkit/components/extensions/ExtensionCommon.sys.mjs new file mode 100644 index 0000000000..86c99042b6 --- /dev/null +++ b/toolkit/components/extensions/ExtensionCommon.sys.mjs @@ -0,0 +1,3082 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +/** + * This module contains utilities and base classes for logic which is + * common between the parent and child process, and in particular + * between ExtensionParent.jsm and ExtensionChild.jsm. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ConsoleAPI: "resource://gre/modules/Console.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SchemaRoot: "resource://gre/modules/Schemas.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "styleSheetService", + "@mozilla.org/content/style-sheet-service;1", + "nsIStyleSheetService" +); + +const ScriptError = Components.Constructor( + "@mozilla.org/scripterror;1", + "nsIScriptError", + "initWithWindowID" +); + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +var { + DefaultMap, + DefaultWeakMap, + ExtensionError, + filterStack, + getInnerWindowID, + getUniqueId, +} = ExtensionUtils; + +function getConsole() { + return new lazy.ConsoleAPI({ + maxLogLevelPref: "extensions.webextensions.log.level", + prefix: "WebExtensions", + }); +} + +// Run a function and report exceptions. +function runSafeSyncWithoutClone(f, ...args) { + try { + return f(...args); + } catch (e) { + // This method is called with `this` unbound and it doesn't have + // access to a BaseContext instance and so we can't check if `e` + // is an instance of the extension context's Error constructor + // (like we do in BaseContext applySafeWithoutClone method). + dump( + `Extension error: ${e} ${e?.fileName} ${ + e?.lineNumber + }\n[[Exception stack\n${ + e?.stack ? filterStack(e) : undefined + }Current stack\n${filterStack(Error())}]]\n` + ); + Cu.reportError(e); + } +} + +// Return true if the given value is an instance of the given +// native type. +function instanceOf(value, type) { + return ( + value && + typeof value === "object" && + ChromeUtils.getClassName(value) === type + ); +} + +/** + * Convert any of several different representations of a date/time to a Date object. + * Accepts several formats: + * a Date object, an ISO8601 string, or a number of milliseconds since the epoch as + * either a number or a string. + * + * @param {Date|string|number} date + * The date to convert. + * @returns {Date} + * A Date object + */ +function normalizeTime(date) { + // Of all the formats we accept the "number of milliseconds since the epoch as a string" + // is an outlier, everything else can just be passed directly to the Date constructor. + return new Date( + typeof date == "string" && /^\d+$/.test(date) ? parseInt(date, 10) : date + ); +} + +function withHandlingUserInput(window, callable) { + let handle = window.windowUtils.setHandlingUserInput(true); + try { + return callable(); + } finally { + handle.destruct(); + } +} + +/** + * Defines a lazy getter for the given property on the given object. The + * first time the property is accessed, the return value of the getter + * is defined on the current `this` object with the given property name. + * Importantly, this means that a lazy getter defined on an object + * prototype will be invoked separately for each object instance that + * it's accessed on. + * + * Note: for better type inference, prefer redefineGetter() below. + * + * @param {object} object + * The prototype object on which to define the getter. + * @param {string | symbol} prop + * The property name for which to define the getter. + * @param {callback} getter + * The function to call in order to generate the final property + * value. + */ +function defineLazyGetter(object, prop, getter) { + Object.defineProperty(object, prop, { + enumerable: true, + configurable: true, + get() { + return redefineGetter(this, prop, getter.call(this), true); + }, + set(value) { + redefineGetter(this, prop, value, true); + }, + }); +} + +/** + * A more type-inference friendly version of defineLazyGetter() above. + * Call it from a real getter (and setter) for your class or object. + * On first run, it will redefine the property with the final value. + * + * @template Value + * @param {object} object + * @param {string | symbol} key + * @param {Value} value + * @returns {Value} + */ +function redefineGetter(object, key, value, writable = false) { + Object.defineProperty(object, key, { + enumerable: true, + configurable: true, + writable, + value, + }); + return value; +} + +function checkLoadURI(uri, principal, options) { + let ssm = Services.scriptSecurityManager; + + let flags = ssm.STANDARD; + if (!options.allowScript) { + flags |= ssm.DISALLOW_SCRIPT; + } + if (!options.allowInheritsPrincipal) { + flags |= ssm.DISALLOW_INHERIT_PRINCIPAL; + } + if (options.dontReportErrors) { + flags |= ssm.DONT_REPORT_ERRORS; + } + + try { + ssm.checkLoadURIWithPrincipal(principal, uri, flags); + } catch (e) { + return false; + } + return true; +} + +function checkLoadURL(url, principal, options) { + try { + return checkLoadURI(Services.io.newURI(url), principal, options); + } catch (e) { + return false; // newURI threw. + } +} + +function makeWidgetId(id) { + id = id.toLowerCase(); + // FIXME: This allows for collisions. + return id.replace(/[^a-z0-9_-]/g, "_"); +} + +/** + * A sentinel class to indicate that an array of values should be + * treated as an array when used as a promise resolution value, but as a + * spread expression (...args) when passed to a callback. + */ +class SpreadArgs extends Array { + constructor(args) { + super(); + this.push(...args); + } +} + +/** + * Like SpreadArgs, but also indicates that the array values already + * belong to the target compartment, and should not be cloned before + * being passed. + * + * The `unwrappedValues` property contains an Array object which belongs + * to the target compartment, and contains the same unwrapped values + * passed the NoCloneSpreadArgs constructor. + */ +class NoCloneSpreadArgs { + constructor(args) { + this.unwrappedValues = args; + } + + [Symbol.iterator]() { + return this.unwrappedValues[Symbol.iterator](); + } +} + +const LISTENERS = Symbol("listeners"); +const ONCE_MAP = Symbol("onceMap"); + +class EventEmitter { + constructor() { + this[LISTENERS] = new Map(); + this[ONCE_MAP] = new WeakMap(); + } + + /** + * Checks whether there is some listener for the given event. + * + * @param {string} event + * The name of the event to listen for. + * @returns {boolean} + */ + has(event) { + return this[LISTENERS].has(event); + } + + /** + * Adds the given function as a listener for the given event. + * + * The listener function may optionally return a Promise which + * resolves when it has completed all operations which event + * dispatchers may need to block on. + * + * @param {string} event + * The name of the event to listen for. + * @param {function(string, ...any): any} listener + * The listener to call when events are emitted. + */ + on(event, listener) { + let listeners = this[LISTENERS].get(event); + if (!listeners) { + listeners = new Set(); + this[LISTENERS].set(event, listeners); + } + + listeners.add(listener); + } + + /** + * Removes the given function as a listener for the given event. + * + * @param {string} event + * The name of the event to stop listening for. + * @param {function(string, ...any): any} listener + * The listener function to remove. + */ + off(event, listener) { + let set = this[LISTENERS].get(event); + if (set) { + set.delete(listener); + set.delete(this[ONCE_MAP].get(listener)); + if (!set.size) { + this[LISTENERS].delete(event); + } + } + } + + /** + * Adds the given function as a listener for the given event once. + * + * @param {string} event + * The name of the event to listen for. + * @param {function(string, ...any): any} listener + * The listener to call when events are emitted. + */ + once(event, listener) { + let wrapper = (event, ...args) => { + this.off(event, wrapper); + this[ONCE_MAP].delete(listener); + + return listener(event, ...args); + }; + this[ONCE_MAP].set(listener, wrapper); + + this.on(event, wrapper); + } + + /** + * Triggers all listeners for the given event. If any listeners return + * a value, returns a promise which resolves when all returned + * promises have resolved. Otherwise, returns undefined. + * + * @param {string} event + * The name of the event to emit. + * @param {any} args + * Arbitrary arguments to pass to the listener functions, after + * the event name. + * @returns {Promise?} + */ + emit(event, ...args) { + let listeners = this[LISTENERS].get(event); + + if (listeners) { + let promises = []; + + for (let listener of listeners) { + try { + let result = listener(event, ...args); + if (result !== undefined) { + promises.push(result); + } + } catch (e) { + Cu.reportError(e); + } + } + + if (promises.length) { + return Promise.all(promises); + } + } + } +} + +/** + * Base class for WebExtension APIs. Each API creates a new class + * that inherits from this class, the derived class is instantiated + * once for each extension that uses the API. + */ +class ExtensionAPI extends EventEmitter { + constructor(extension) { + super(); + + this.extension = extension; + + extension.once("shutdown", (what, isAppShutdown) => { + if (this.onShutdown) { + this.onShutdown(isAppShutdown); + } + this.extension = null; + }); + } + + destroy() {} + + /** @param {string} entryName */ + onManifestEntry(entryName) {} + + /** @param {boolean} isAppShutdown */ + onShutdown(isAppShutdown) {} + + /** @param {BaseContext} context */ + getAPI(context) { + throw new Error("Not Implemented"); + } + + /** @param {string} id */ + static onDisable(id) {} + + /** @param {string} id */ + static onUninstall(id) {} + + /** + * @param {string} id + * @param {Record} manifest + */ + static onUpdate(id, manifest) {} +} + +/** + * Subclass to add APIs commonly used with persistent events. + * If a namespace uses events, it should use this subclass. + * + * this.apiNamespace = class extends ExtensionAPIPersistent {}; + */ +class ExtensionAPIPersistent extends ExtensionAPI { + /** @type {Record} */ + PERSISTENT_EVENTS; + + /** + * Check for event entry. + * + * @param {string} event The event name e.g. onStateChanged + * @returns {boolean} + */ + hasEventRegistrar(event) { + return ( + this.PERSISTENT_EVENTS && Object.hasOwn(this.PERSISTENT_EVENTS, event) + ); + } + + /** + * Get the event registration fuction + * + * @param {string} event The event name e.g. onStateChanged + * @returns {Function} register is used to start the listener + * register returns an object containing + * a convert and unregister function. + */ + getEventRegistrar(event) { + if (this.hasEventRegistrar(event)) { + return this.PERSISTENT_EVENTS[event].bind(this); + } + } + + /** + * Used when instantiating an EventManager instance to register the listener. + * + * @param {object} options Options used for event registration + * @param {BaseContext} options.context Extension Context passed when creating an EventManager instance. + * @param {string} options.event The eAPI vent name. + * @param {Function} options.fire The function passed to the listener to fire the event. + * @param {Array} params An optional array of parameters received along with the + * addListener request. + * @returns {Function} The unregister function used in the EventManager. + */ + registerEventListener(options, params) { + const apiRegistar = this.getEventRegistrar(options.event); + return apiRegistar?.(options, params).unregister; + } + + /** + * Used to prime a listener for when the background script is not running. + * + * @param {string} event The event name e.g. onStateChanged or captiveURL.onChange. + * @param {Function} fire The function passed to the listener to fire the event. + * @param {Array} params Params passed to the event listener. + * @param {boolean} isInStartup unused here but passed for subclass use. + * @returns {object} the unregister and convert functions used in the EventManager. + */ + primeListener(event, fire, params, isInStartup) { + const apiRegistar = this.getEventRegistrar(event); + return apiRegistar?.({ fire, isInStartup }, params); + } +} + +/** + * This class contains the information we have about an individual + * extension. It is never instantiated directly, instead subclasses + * for each type of process extend this class and add members that are + * relevant for that process. + * + * @abstract + */ +class BaseContext { + /** @type {boolean} */ + isTopContext; + /** @type {string} */ + viewType; + + constructor(envType, extension) { + this.envType = envType; + this.onClose = new Set(); + this.checkedLastError = false; + this._lastError = null; + this.contextId = getUniqueId(); + this.unloaded = false; + this.extension = extension; + this.manifestVersion = extension.manifestVersion; + this.jsonSandbox = null; + this.active = true; + this.incognito = null; + this.messageManager = null; + this.contentWindow = null; + this.innerWindowID = 0; + + // These two properties are assigned in ContentScriptContextChild subclass + // to keep a copy of the content script sandbox Error and Promise globals + // (which are used by the WebExtensions internals) before any extension + // content script code had any chance to redefine them. + this.cloneScopeError = null; + this.cloneScopePromise = null; + } + + get isProxyContextParent() { + return false; + } + + get Error() { + // Return the copy stored in the context instance (when the context is an instance of + // ContentScriptContextChild or the global from extension page window otherwise). + return this.cloneScopeError || this.cloneScope.Error; + } + + get Promise() { + // Return the copy stored in the context instance (when the context is an instance of + // ContentScriptContextChild or the global from extension page window otherwise). + return this.cloneScopePromise || this.cloneScope.Promise; + } + + get privateBrowsingAllowed() { + return this.extension.privateBrowsingAllowed; + } + + get isBackgroundContext() { + if (this.viewType === "background") { + if (this.isProxyContextParent) { + return !!this.isTopContext; // Set in ExtensionPageContextParent. + } + const { contentWindow } = this; + return !!contentWindow && contentWindow.top === contentWindow; + } + return this.viewType === "background_worker"; + } + + /** + * Whether the extension context is using the WebIDL bindings for the + * WebExtensions APIs. + * To be overridden in subclasses (e.g. WorkerContextChild) and to be + * optionally used in ExtensionAPI classes to customize the behavior of the + * API when the calls to the extension API are originated from the WebIDL + * bindings. + */ + get useWebIDLBindings() { + return false; + } + + canAccessWindow(window) { + return this.extension.canAccessWindow(window); + } + + canAccessContainer(userContextId) { + return this.extension.canAccessContainer(userContextId); + } + + /** + * Opens a conduit linked to this context, populating related address fields. + * Only available in child contexts with an associated contentWindow. + * + * @param {object} subject + * @param {ConduitAddress} address + * @returns {import("ConduitsChild.sys.mjs").PointConduit} + * @type {ConduitOpen} + */ + openConduit(subject, address) { + let wgc = this.contentWindow.windowGlobalChild; + let conduit = wgc.getActor("Conduits").openConduit(subject, { + id: subject.id || getUniqueId(), + extensionId: this.extension.id, + envType: this.envType, + ...address, + }); + this.callOnClose(conduit); + conduit.setCloseCallback(() => { + this.forgetOnClose(conduit); + }); + return conduit; + } + + setContentWindow(contentWindow) { + if (!this.canAccessWindow(contentWindow)) { + throw new Error( + "BaseContext attempted to load when extension is not allowed due to incognito settings." + ); + } + + this.innerWindowID = getInnerWindowID(contentWindow); + this.messageManager = contentWindow.docShell.messageManager; + + if (this.incognito == null) { + this.incognito = + lazy.PrivateBrowsingUtils.isContentWindowPrivate(contentWindow); + } + + let wgc = contentWindow.windowGlobalChild; + Object.defineProperty(this, "active", { + configurable: true, + enumerable: true, + get: () => wgc.isCurrentGlobal && !wgc.windowContext.isInBFCache, + }); + Object.defineProperty(this, "contentWindow", { + configurable: true, + enumerable: true, + get: () => (this.active ? wgc.browsingContext.window : null), + }); + this.callOnClose({ + close: () => { + // Allow other "close" handlers to use these properties, until the next tick. + Promise.resolve().then(() => { + Object.defineProperty(this, "contentWindow", { value: null }); + Object.defineProperty(this, "active", { value: false }); + wgc = null; + }); + }, + }); + } + + // All child contexts must implement logActivity. This is handled if the child + // context subclasses ExtensionBaseContextChild. ProxyContextParent overrides + // this with a noop for parent contexts. + logActivity(type, name, data) { + throw new Error(`Not implemented for ${this.envType}`); + } + + /** @type {object} */ + get cloneScope() { + throw new Error("Not implemented"); + } + + /** @type {nsIPrincipal} */ + get principal() { + throw new Error("Not implemented"); + } + + runSafe(callback, ...args) { + return this.applySafe(callback, args); + } + + runSafeWithoutClone(callback, ...args) { + return this.applySafeWithoutClone(callback, args); + } + + applySafe(callback, args, caller) { + if (this.unloaded) { + Cu.reportError("context.runSafe called after context unloaded", caller); + } else if (!this.active) { + Cu.reportError( + "context.runSafe called while context is inactive", + caller + ); + } else { + try { + let { cloneScope } = this; + args = args.map(arg => Cu.cloneInto(arg, cloneScope)); + } catch (e) { + Cu.reportError(e); + dump( + `runSafe failure: cloning into ${ + this.cloneScope + }: ${e}\n\n${filterStack(Error())}` + ); + } + + return this.applySafeWithoutClone(callback, args, caller); + } + } + + applySafeWithoutClone(callback, args, caller) { + if (this.unloaded) { + Cu.reportError( + "context.runSafeWithoutClone called after context unloaded", + caller + ); + } else if (!this.active) { + Cu.reportError( + "context.runSafeWithoutClone called while context is inactive", + caller + ); + } else { + try { + return Reflect.apply(callback, null, args); + } catch (e) { + // An extension listener may as well be throwing an object that isn't + // an instance of Error, in that case we have to use fallbacks for the + // error message, fileName, lineNumber and columnNumber properties. + const isError = e instanceof this.Error; + let message; + let fileName; + let lineNumber; + let columnNumber; + + if (isError) { + message = `${e.name}: ${e.message}`; + lineNumber = e.lineNumber; + columnNumber = e.columnNumber; + fileName = e.fileName; + } else { + message = `uncaught exception: ${e}`; + + try { + // TODO(Bug 1810582): the following fallback logic may go away once + // we introduced a better way to capture and log the exception in + // the right window and in all cases (included when the extension + // code is raising undefined or an object that isn't an instance of + // the Error constructor). + // + // Fallbacks for the error location: + // - the callback location if it is registered directly from the + // extension code (and not wrapped by the child/ext-APINAMe.js + // implementation, like e.g. browser.storage, browser.devtools.network + // are doing and browser.menus). + // - if the location of the extension callback is not directly + // available (e.g. browser.storage onChanged events, and similarly + // for browser.devtools.network and browser.menus events): + // - the extension page url if the context is an extension page + // - the extension base url if the context is a content script + const cbLoc = Cu.getFunctionSourceLocation(callback); + fileName = cbLoc.filename; + lineNumber = cbLoc.lineNumber ?? lineNumber; + + const extBaseUrl = this.extension.baseURI.resolve("/"); + if (fileName.startsWith(extBaseUrl)) { + fileName = cbLoc.filename; + lineNumber = cbLoc.lineNumber ?? lineNumber; + } else { + fileName = this.contentWindow?.location?.href; + if (!fileName || !fileName.startsWith(extBaseUrl)) { + fileName = extBaseUrl; + } + } + } catch { + // Ignore errors on retrieving the callback source location. + } + } + + dump( + `Extension error: ${message} ${fileName} ${lineNumber}\n[[Exception stack\n${ + isError ? filterStack(e) : undefined + }Current stack\n${filterStack(Error())}]]\n` + ); + + // If the error is coming from an extension context associated + // to a window (e.g. an extension page or extension content script). + // + // TODO(Bug 1810574): for the background service worker we will need to do + // something similar, but not tied to the innerWindowID because there + // wouldn't be one set for extension contexts related to the + // background service worker. + // + // TODO(Bug 1810582): change the error associated to the innerWindowID to also + // include a full stack from the original error. + if (!this.isProxyContextParent && this.contentWindow) { + Services.console.logMessage( + new ScriptError( + message, + fileName, + null, + lineNumber, + columnNumber, + Ci.nsIScriptError.errorFlag, + "content javascript", + this.innerWindowID + ) + ); + } + // Also report the original error object (because it also includes + // the full error stack). + Cu.reportError(e); + } + } + } + + checkLoadURL(url, options = {}) { + // As an optimization, f the URL starts with the extension's base URL, + // don't do any further checks. It's always allowed to load it. + if (url.startsWith(this.extension.baseURL)) { + return true; + } + + return checkLoadURL(url, this.principal, options); + } + + /** + * Safely call JSON.stringify() on an object that comes from an + * extension. + * + * @param {[any, callback?, number?]} args for JSON.stringify() + * @returns {string} The stringified representation of obj + */ + jsonStringify(...args) { + if (!this.jsonSandbox) { + this.jsonSandbox = Cu.Sandbox(this.principal, { + sameZoneAs: this.cloneScope, + wantXrays: false, + }); + } + + return Cu.waiveXrays(this.jsonSandbox.JSON).stringify(...args); + } + + callOnClose(obj) { + this.onClose.add(obj); + } + + forgetOnClose(obj) { + this.onClose.delete(obj); + } + + get lastError() { + this.checkedLastError = true; + return this._lastError; + } + + set lastError(val) { + this.checkedLastError = false; + this._lastError = val; + } + + /** + * Normalizes the given error object for use by the target scope. If + * the target is an error object which belongs to that scope, it is + * returned as-is. If it is an ordinary object with a `message` + * property, it is converted into an error belonging to the target + * scope. If it is an Error object which does *not* belong to the + * clone scope, it is reported, and converted to an unexpected + * exception error. + * + * @param {Error|object} error + * @param {SavedFrame?} [caller] + * @returns {Error} + */ + normalizeError(error, caller) { + if (error instanceof this.Error) { + return error; + } + let message, fileName; + if (error && typeof error === "object") { + const isPlain = ChromeUtils.getClassName(error) === "Object"; + if (isPlain && error.mozWebExtLocation) { + caller = error.mozWebExtLocation; + } + if (isPlain && caller && (error.mozWebExtLocation || !error.fileName)) { + caller = Cu.cloneInto(caller, this.cloneScope); + return ChromeUtils.createError(error.message, caller); + } + + if ( + isPlain || + error instanceof ExtensionError || + this.principal.subsumes(Cu.getObjectPrincipal(error)) + ) { + message = error.message; + fileName = error.fileName; + } + } + + if (!message) { + Cu.reportError(error); + message = "An unexpected error occurred"; + } + return new this.Error(message, fileName); + } + + /** + * Sets the value of `.lastError` to `error`, calls the given + * callback, and reports an error if the value has not been checked + * when the callback returns. + * + * @param {object} error An object with a `message` property. May + * optionally be an `Error` object belonging to the target scope. + * @param {SavedFrame?} caller + * The optional caller frame which triggered this callback, to be used + * in error reporting. + * @param {Function} callback The callback to call. + * @returns {*} The return value of callback. + */ + withLastError(error, caller, callback) { + this.lastError = this.normalizeError(error); + try { + return callback(); + } finally { + if (!this.checkedLastError) { + Cu.reportError(`Unchecked lastError value: ${this.lastError}`, caller); + } + this.lastError = null; + } + } + + /** + * Captures the most recent stack frame which belongs to the extension. + * + * @returns {SavedFrame?} + */ + getCaller() { + return ChromeUtils.getCallerLocation(this.principal); + } + + /** + * Wraps the given promise so it can be safely returned to extension + * code in this context. + * + * If `callback` is provided, however, it is used as a completion + * function for the promise, and no promise is returned. In this case, + * the callback is called when the promise resolves or rejects. In the + * latter case, `lastError` is set to the rejection value, and the + * callback function must check `browser.runtime.lastError` or + * `extension.runtime.lastError` in order to prevent it being reported + * to the console. + * + * @param {Promise} promise The promise with which to wrap the + * callback. May resolve to a `SpreadArgs` instance, in which case + * each element will be used as a separate argument. + * + * Unless the promise object belongs to the cloneScope global, its + * resolution value is cloned into cloneScope prior to calling the + * `callback` function or resolving the wrapped promise. + * + * @param {Function} [callback] The callback function to wrap + * + * @returns {Promise|undefined} If callback is null, a promise object + * belonging to the target scope. Otherwise, undefined. + */ + wrapPromise(promise, callback = null) { + let caller = this.getCaller(); + let applySafe = this.applySafe.bind(this); + if (Cu.getGlobalForObject(promise) === this.cloneScope) { + applySafe = this.applySafeWithoutClone.bind(this); + } + + if (callback) { + promise.then( + args => { + if (this.unloaded) { + Cu.reportError(`Promise resolved after context unloaded\n`, caller); + } else if (!this.active) { + Cu.reportError( + `Promise resolved while context is inactive\n`, + caller + ); + } else if (args instanceof NoCloneSpreadArgs) { + this.applySafeWithoutClone(callback, args.unwrappedValues, caller); + } else if (args instanceof SpreadArgs) { + applySafe(callback, args, caller); + } else { + applySafe(callback, [args], caller); + } + }, + error => { + this.withLastError(error, caller, () => { + if (this.unloaded) { + Cu.reportError( + `Promise rejected after context unloaded\n`, + caller + ); + } else if (!this.active) { + Cu.reportError( + `Promise rejected while context is inactive\n`, + caller + ); + } else { + this.applySafeWithoutClone(callback, [], caller); + } + }); + } + ); + } else { + return new this.Promise((resolve, reject) => { + promise.then( + value => { + if (this.unloaded) { + Cu.reportError( + `Promise resolved after context unloaded\n`, + caller + ); + } else if (!this.active) { + Cu.reportError( + `Promise resolved while context is inactive\n`, + caller + ); + } else if (value instanceof NoCloneSpreadArgs) { + let values = value.unwrappedValues; + this.applySafeWithoutClone( + resolve, + values.length == 1 ? [values[0]] : [values], + caller + ); + } else if (value instanceof SpreadArgs) { + applySafe(resolve, value.length == 1 ? value : [value], caller); + } else { + applySafe(resolve, [value], caller); + } + }, + value => { + if (this.unloaded) { + Cu.reportError( + `Promise rejected after context unloaded: ${ + value && value.message + }\n`, + caller + ); + } else if (!this.active) { + Cu.reportError( + `Promise rejected while context is inactive: ${ + value && value.message + }\n`, + caller + ); + } else { + this.applySafeWithoutClone( + reject, + [this.normalizeError(value, caller)], + caller + ); + } + } + ); + }); + } + } + + unload() { + this.unloaded = true; + + for (let obj of this.onClose) { + obj.close(); + } + this.onClose.clear(); + } + + /** + * A simple proxy for unload(), for use with callOnClose(). + */ + close() { + this.unload(); + } +} + +/** + * An object that runs the implementation of a schema API. Instantiations of + * this interfaces are used by Schemas.jsm. + * + * @interface + */ +class SchemaAPIInterface { + /** + * Calls this as a function that returns its return value. + * + * @abstract + * @param {Array} args The parameters for the function. + * @returns {*} The return value of the invoked function. + */ + callFunction(args) { + throw new Error("Not implemented"); + } + + /** + * Calls this as a function and ignores its return value. + * + * @abstract + * @param {Array} args The parameters for the function. + */ + callFunctionNoReturn(args) { + throw new Error("Not implemented"); + } + + /** + * Calls this as a function that completes asynchronously. + * + * @abstract + * @param {Array} args The parameters for the function. + * @param {callback} [callback] The callback to be called when the function + * completes. + * @param {boolean} [requireUserInput=false] If true, the function should + * fail if the browser is not currently handling user input. + * @returns {Promise|undefined} Must be void if `callback` is set, and a + * promise otherwise. The promise is resolved when the function completes. + */ + callAsyncFunction(args, callback, requireUserInput = false) { + throw new Error("Not implemented"); + } + + /** + * Retrieves the value of this as a property. + * + * @abstract + * @returns {*} The value of the property. + */ + getProperty() { + throw new Error("Not implemented"); + } + + /** + * Assigns the value to this as property. + * + * @abstract + * @param {string} value The new value of the property. + */ + setProperty(value) { + throw new Error("Not implemented"); + } + + /** + * Registers a `listener` to this as an event. + * + * @abstract + * @param {Function} listener The callback to be called when the event fires. + * @param {Array} args Extra parameters for EventManager.addListener. + * @see EventManager.addListener + */ + addListener(listener, args) { + throw new Error("Not implemented"); + } + + /** + * Checks whether `listener` is listening to this as an event. + * + * @abstract + * @param {Function} listener The event listener. + * @returns {boolean} Whether `listener` is registered with this as an event. + * @see EventManager.hasListener + */ + hasListener(listener) { + throw new Error("Not implemented"); + } + + /** + * Unregisters `listener` from this as an event. + * + * @abstract + * @param {Function} listener The event listener. + * @see EventManager.removeListener + */ + removeListener(listener) { + throw new Error("Not implemented"); + } + + /** + * Revokes the implementation object, and prevents any further method + * calls from having external effects. + * + * @abstract + */ + revoke() { + throw new Error("Not implemented"); + } +} + +/** + * An object that runs a locally implemented API. + */ +class LocalAPIImplementation extends SchemaAPIInterface { + /** + * Constructs an implementation of the `name` method or property of `pathObj`. + * + * @param {object} pathObj The object containing the member with name `name`. + * @param {string} name The name of the implemented member. + * @param {BaseContext} context The context in which the schema is injected. + */ + constructor(pathObj, name, context) { + super(); + this.pathObj = pathObj; + this.name = name; + this.context = context; + } + + revoke() { + if (this.pathObj[this.name][lazy.Schemas.REVOKE]) { + this.pathObj[this.name][lazy.Schemas.REVOKE](); + } + + this.pathObj = null; + this.name = null; + this.context = null; + } + + callFunction(args) { + try { + return this.pathObj[this.name](...args); + } catch (e) { + throw this.context.normalizeError(e); + } + } + + callFunctionNoReturn(args) { + try { + this.pathObj[this.name](...args); + } catch (e) { + throw this.context.normalizeError(e); + } + } + + callAsyncFunction(args, callback, requireUserInput) { + let promise; + try { + if (requireUserInput) { + if (!this.context.contentWindow.windowUtils.isHandlingUserInput) { + throw new ExtensionError( + `${this.name} may only be called from a user input handler` + ); + } + } + promise = this.pathObj[this.name](...args) || Promise.resolve(); + } catch (e) { + promise = Promise.reject(e); + } + return this.context.wrapPromise(promise, callback); + } + + getProperty() { + return this.pathObj[this.name]; + } + + setProperty(value) { + this.pathObj[this.name] = value; + } + + addListener(listener, args) { + try { + this.pathObj[this.name].addListener.call(null, listener, ...args); + } catch (e) { + throw this.context.normalizeError(e); + } + } + + hasListener(listener) { + return this.pathObj[this.name].hasListener.call(null, listener); + } + + removeListener(listener) { + this.pathObj[this.name].removeListener.call(null, listener); + } +} + +// Recursively copy properties from source to dest. +function deepCopy(dest, source) { + for (let prop in source) { + let desc = Object.getOwnPropertyDescriptor(source, prop); + if (typeof desc.value == "object") { + if (!(prop in dest)) { + dest[prop] = {}; + } + deepCopy(dest[prop], source[prop]); + } else { + Object.defineProperty(dest, prop, desc); + } + } +} + +function getChild(map, key) { + let child = map.children.get(key); + if (!child) { + child = { + modules: new Set(), + children: new Map(), + }; + + map.children.set(key, child); + } + return child; +} + +function getPath(map, path) { + for (let key of path) { + map = getChild(map, key); + } + return map; +} + +function mergePaths(dest, source) { + for (let name of source.modules) { + dest.modules.add(name); + } + + for (let [name, child] of source.children.entries()) { + mergePaths(getChild(dest, name), child); + } +} + +/** + * Manages loading and accessing a set of APIs for a specific extension + * context. + * + * @param {BaseContext} context + * The context to manage APIs for. + * @param {SchemaAPIManager} apiManager + * The API manager holding the APIs to manage. + * @param {object} root + * The root object into which APIs will be injected. + */ +class CanOfAPIs { + constructor(context, apiManager, root) { + this.context = context; + this.scopeName = context.envType; + this.apiManager = apiManager; + this.root = root; + + this.apiPaths = new Map(); + + this.apis = new Map(); + } + + /** + * Synchronously loads and initializes an ExtensionAPI instance. + * + * @param {string} name + * The name of the API to load. + */ + loadAPI(name) { + if (this.apis.has(name)) { + return; + } + + let { extension } = this.context; + + let api = this.apiManager.getAPI(name, extension, this.scopeName); + if (!api) { + return; + } + + this.apis.set(name, api); + + deepCopy(this.root, api.getAPI(this.context)); + } + + /** + * Asynchronously loads and initializes an ExtensionAPI instance. + * + * @param {string} name + * The name of the API to load. + */ + async asyncLoadAPI(name) { + if (this.apis.has(name)) { + return; + } + + let { extension } = this.context; + if (!lazy.Schemas.checkPermissions(name, extension)) { + return; + } + + let api = await this.apiManager.asyncGetAPI( + name, + extension, + this.scopeName + ); + // Check again, because async; + if (this.apis.has(name)) { + return; + } + + this.apis.set(name, api); + + deepCopy(this.root, api.getAPI(this.context)); + } + + /** + * Finds the API at the given path from the root object, and + * synchronously loads the API that implements it if it has not + * already been loaded. + * + * @param {string} path + * The "."-separated path to find. + * @returns {*} + */ + findAPIPath(path) { + if (this.apiPaths.has(path)) { + return this.apiPaths.get(path); + } + + let obj = this.root; + let modules = this.apiManager.modulePaths; + + let parts = path.split("."); + for (let [i, key] of parts.entries()) { + if (!obj) { + return; + } + modules = getChild(modules, key); + + for (let name of modules.modules) { + if (!this.apis.has(name)) { + this.loadAPI(name); + } + } + + if (!(key in obj) && i < parts.length - 1) { + obj[key] = {}; + } + obj = obj[key]; + } + + this.apiPaths.set(path, obj); + return obj; + } + + /** + * Finds the API at the given path from the root object, and + * asynchronously loads the API that implements it if it has not + * already been loaded. + * + * @param {string} path + * The "."-separated path to find. + * @returns {Promise<*>} + */ + async asyncFindAPIPath(path) { + if (this.apiPaths.has(path)) { + return this.apiPaths.get(path); + } + + let obj = this.root; + let modules = this.apiManager.modulePaths; + + let parts = path.split("."); + for (let [i, key] of parts.entries()) { + if (!obj) { + return; + } + modules = getChild(modules, key); + + for (let name of modules.modules) { + if (!this.apis.has(name)) { + await this.asyncLoadAPI(name); + } + } + + if (!(key in obj) && i < parts.length - 1) { + obj[key] = {}; + } + + if (typeof obj[key] === "function") { + obj = obj[key].bind(obj); + } else { + obj = obj[key]; + } + } + + this.apiPaths.set(path, obj); + return obj; + } +} + +/** + * @class APIModule + * @abstract + * + * @property {string} url + * The URL of the script which contains the module's + * implementation. This script must define a global property + * matching the modules name, which must be a class constructor + * which inherits from {@link ExtensionAPI}. + * + * @property {string} schema + * The URL of the JSON schema which describes the module's API. + * + * @property {Array} scopes + * The list of scope names into which the API may be loaded. + * + * @property {Array} manifest + * The list of top-level manifest properties which will trigger + * the module to be loaded, and its `onManifestEntry` method to be + * called. + * + * @property {Array} events + * The list events which will trigger the module to be loaded, and + * its appropriate event handler method to be called. Currently + * only accepts "startup". + * + * @property {Array} permissions + * An optional list of permissions, any of which must be present + * in order for the module to load. + * + * @property {Array>} paths + * A list of paths from the root API object which, when accessed, + * will cause the API module to be instantiated and injected. + */ + +/** + * This object loads the ext-*.js scripts that define the extension API. + * + * This class instance is shared with the scripts that it loads, so that the + * ext-*.js scripts and the instantiator can communicate with each other. + */ +class SchemaAPIManager extends EventEmitter { + /** + * @param {string} processType + * "main" - The main, one and only chrome browser process. + * "addon" - An addon process. + * "content" - A content process. + * "devtools" - A devtools process. + * @param {import("Schemas.sys.mjs").SchemaRoot} [schema] + */ + constructor(processType, schema) { + super(); + this.processType = processType; + this.global = null; + if (schema) { + this.schema = schema; + } + + this.modules = new Map(); + this.modulePaths = { children: new Map(), modules: new Set() }; + this.manifestKeys = new Map(); + this.eventModules = new DefaultMap(() => new Set()); + this.settingsModules = new Set(); + + this._modulesJSONLoaded = false; + + this.schemaURLs = new Map(); + + this.apis = new DefaultWeakMap(() => new Map()); + + this._scriptScopes = []; + } + + onStartup(extension) { + let promises = []; + for (let apiName of this.eventModules.get("startup")) { + promises.push( + extension.apiManager.asyncGetAPI(apiName, extension).then(api => { + if (api) { + api.onStartup(); + } + }) + ); + } + + return Promise.all(promises); + } + + async loadModuleJSON(urls) { + let promises = urls.map(url => fetch(url).then(resp => resp.json())); + + return this.initModuleJSON(await Promise.all(promises)); + } + + initModuleJSON(blobs) { + for (let json of blobs) { + this.registerModules(json); + } + + this._modulesJSONLoaded = true; + + return new StructuredCloneHolder("SchemaAPIManager/initModuleJSON", null, { + modules: this.modules, + modulePaths: this.modulePaths, + manifestKeys: this.manifestKeys, + eventModules: this.eventModules, + settingsModules: this.settingsModules, + schemaURLs: this.schemaURLs, + }); + } + + initModuleData(moduleData) { + if (!this._modulesJSONLoaded) { + let data = moduleData.deserialize({}, true); + + this.modules = data.modules; + this.modulePaths = data.modulePaths; + this.manifestKeys = data.manifestKeys; + this.eventModules = new DefaultMap(() => new Set(), data.eventModules); + this.settingsModules = new Set(data.settingsModules); + this.schemaURLs = data.schemaURLs; + } + + this._modulesJSONLoaded = true; + } + + /** + * Registers a set of ExtensionAPI modules to be lazily loaded and + * managed by this manager. + * + * @param {object} obj + * An object containing property for eacy API module to be + * registered. Each value should be an object implementing the + * APIModule interface. + */ + registerModules(obj) { + for (let [name, details] of Object.entries(obj)) { + details.namespaceName = name; + + if (this.modules.has(name)) { + throw new Error(`Module '${name}' already registered`); + } + this.modules.set(name, details); + + if (details.schema) { + let content = + details.scopes && + (details.scopes.includes("content_parent") || + details.scopes.includes("content_child")); + this.schemaURLs.set(details.schema, { content }); + } + + for (let event of details.events || []) { + this.eventModules.get(event).add(name); + } + + if (details.settings) { + this.settingsModules.add(name); + } + + for (let key of details.manifest || []) { + if (this.manifestKeys.has(key)) { + throw new Error( + `Manifest key '${key}' already registered by '${this.manifestKeys.get( + key + )}'` + ); + } + + this.manifestKeys.set(key, name); + } + + for (let path of details.paths || []) { + getPath(this.modulePaths, path).modules.add(name); + } + } + } + + /** + * Emits an `onManifestEntry` event for the top-level manifest entry + * on all relevant {@link ExtensionAPI} instances for the given + * extension. + * + * The API modules will be synchronously loaded if they have not been + * loaded already. + * + * @param {Extension} extension + * The extension for which to emit the events. + * @param {string} entry + * The name of the top-level manifest entry. + * + * @returns {*} + */ + emitManifestEntry(extension, entry) { + let apiName = this.manifestKeys.get(entry); + if (apiName) { + let api = extension.apiManager.getAPI(apiName, extension); + return api.onManifestEntry(entry); + } + } + /** + * Emits an `onManifestEntry` event for the top-level manifest entry + * on all relevant {@link ExtensionAPI} instances for the given + * extension. + * + * The API modules will be asynchronously loaded if they have not been + * loaded already. + * + * @param {Extension} extension + * The extension for which to emit the events. + * @param {string} entry + * The name of the top-level manifest entry. + * + * @returns {Promise<*>} + */ + async asyncEmitManifestEntry(extension, entry) { + let apiName = this.manifestKeys.get(entry); + if (apiName) { + let api = await extension.apiManager.asyncGetAPI(apiName, extension); + return api.onManifestEntry(entry); + } + } + + /** + * Returns the {@link ExtensionAPI} instance for the given API module, + * for the given extension, in the given scope, synchronously loading + * and instantiating it if necessary. + * + * @param {string} name + * The name of the API module to load. + * @param {Extension} extension + * The extension for which to load the API. + * @param {string} [scope = null] + * The scope type for which to retrieve the API, or null if not + * being retrieved for a particular scope. + * + * @returns {ExtensionAPI?} + */ + getAPI(name, extension, scope = null) { + if (!this._checkGetAPI(name, extension, scope)) { + return; + } + + let apis = this.apis.get(extension); + if (apis.has(name)) { + return apis.get(name); + } + + let module = this.loadModule(name); + + let api = new module(extension); + apis.set(name, api); + return api; + } + /** + * Returns the {@link ExtensionAPI} instance for the given API module, + * for the given extension, in the given scope, asynchronously loading + * and instantiating it if necessary. + * + * @param {string} name + * The name of the API module to load. + * @param {Extension} extension + * The extension for which to load the API. + * @param {string} [scope = null] + * The scope type for which to retrieve the API, or null if not + * being retrieved for a particular scope. + * + * @returns {Promise?} + */ + async asyncGetAPI(name, extension, scope = null) { + if (!this._checkGetAPI(name, extension, scope)) { + return; + } + + let apis = this.apis.get(extension); + if (apis.has(name)) { + return apis.get(name); + } + + let module = await this.asyncLoadModule(name); + + // Check again, because async. + if (apis.has(name)) { + return apis.get(name); + } + + let api = new module(extension); + apis.set(name, api); + return api; + } + + /** + * Synchronously loads an API module, if not already loaded, and + * returns its ExtensionAPI constructor. + * + * @param {string} name + * The name of the module to load. + * @returns {typeof ExtensionAPI} + */ + loadModule(name) { + let module = this.modules.get(name); + if (module.loaded) { + return this.global[name]; + } + + this._checkLoadModule(module, name); + + this.initGlobal(); + + Services.scriptloader.loadSubScript(module.url, this.global); + + module.loaded = true; + + return this.global[name]; + } + /** + * aSynchronously loads an API module, if not already loaded, and + * returns its ExtensionAPI constructor. + * + * @param {string} name + * The name of the module to load. + * + * @returns {Promise} + */ + asyncLoadModule(name) { + let module = this.modules.get(name); + if (module.loaded) { + return Promise.resolve(this.global[name]); + } + if (module.asyncLoaded) { + return module.asyncLoaded; + } + + this._checkLoadModule(module, name); + + module.asyncLoaded = ChromeUtils.compileScript(module.url).then(script => { + this.initGlobal(); + script.executeInGlobal(this.global); + + module.loaded = true; + + return this.global[name]; + }); + + return module.asyncLoaded; + } + + asyncLoadSettingsModules() { + return Promise.all( + Array.from(this.settingsModules).map(apiName => + this.asyncLoadModule(apiName) + ) + ); + } + + getModule(name) { + return this.modules.get(name); + } + + /** + * Checks whether the given API module may be loaded for the given + * extension, in the given scope. + * + * @param {string} name + * The name of the API module to check. + * @param {Extension} extension + * The extension for which to check the API. + * @param {string} [scope = null] + * The scope type for which to check the API, or null if not + * being checked for a particular scope. + * + * @returns {boolean} + * Whether the module may be loaded. + */ + _checkGetAPI(name, extension, scope = null) { + let module = this.getModule(name); + if (!module) { + // A module may not exist for a particular manifest version, but + // we allow keys in the manifest. An example is pageAction. + return false; + } + + if ( + module.permissions && + !module.permissions.some(perm => extension.hasPermission(perm)) + ) { + return false; + } + + if (!scope) { + return true; + } + + if (!module.scopes.includes(scope)) { + return false; + } + + if (!lazy.Schemas.checkPermissions(module.namespaceName, extension)) { + return false; + } + + return true; + } + + _checkLoadModule(module, name) { + if (!module) { + throw new Error(`Module '${name}' does not exist`); + } + if (module.asyncLoaded) { + throw new Error(`Module '${name}' currently being lazily loaded`); + } + if (this.global && this.global[name]) { + throw new Error( + `Module '${name}' conflicts with existing global property` + ); + } + } + + /** + * Create a global object that is used as the shared global for all ext-*.js + * scripts that are loaded via `loadScript`. + * + * @returns {object} A sandbox that is used as the global by `loadScript`. + */ + _createExtGlobal() { + let global = Cu.Sandbox( + Services.scriptSecurityManager.getSystemPrincipal(), + { + wantXrays: false, + wantGlobalProperties: ["ChromeUtils"], + sandboxName: `Namespace of ext-*.js scripts for ${this.processType} (from: resource://gre/modules/ExtensionCommon.jsm)`, + } + ); + + Object.assign(global, { + AppConstants, + Cc, + ChromeWorker, + Ci, + Cr, + Cu, + ExtensionAPI, + ExtensionAPIPersistent, + ExtensionCommon, + IOUtils, + MatchGlob, + MatchPattern, + MatchPatternSet, + PathUtils, + Services, + StructuredCloneHolder, + WebExtensionPolicy, + XPCOMUtils, + extensions: this, + global, + }); + + ChromeUtils.defineLazyGetter(global, "console", getConsole); + // eslint-disable-next-line mozilla/lazy-getter-object-name + ChromeUtils.defineESModuleGetters(global, { + ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs", + }); + + return global; + } + + initGlobal() { + if (!this.global) { + this.global = this._createExtGlobal(); + } + } + + /** + * Load an ext-*.js script. The script runs in its own scope, if it wishes to + * share state with another script it can assign to the `global` variable. If + * it wishes to communicate with this API manager, use `extensions`. + * + * @param {string} scriptUrl The URL of the ext-*.js script. + */ + loadScript(scriptUrl) { + // Create the object in the context of the sandbox so that the script runs + // in the sandbox's context instead of here. + let scope = Cu.createObjectIn(this.global); + + Services.scriptloader.loadSubScript(scriptUrl, scope); + + // Save the scope to avoid it being garbage collected. + this._scriptScopes.push(scope); + } +} + +class LazyAPIManager extends SchemaAPIManager { + constructor(processType, moduleData, schemaURLs) { + super(processType); + + /** @type {Promise | boolean} */ + this.initialized = false; + + this.initModuleData(moduleData); + + this.schemaURLs = schemaURLs; + } + + lazyInit() {} +} + +defineLazyGetter(LazyAPIManager.prototype, "schema", function () { + let root = new lazy.SchemaRoot(lazy.Schemas.rootSchema, this.schemaURLs); + root.parseSchemas(); + return root; +}); + +class MultiAPIManager extends SchemaAPIManager { + constructor(processType, children) { + super(processType); + + this.initialized = false; + + this.children = children; + } + + async lazyInit() { + if (!this.initialized) { + this.initialized = true; + + for (let child of this.children) { + if (child.lazyInit) { + let res = child.lazyInit(); + if (res && typeof res.then === "function") { + await res; + } + } + + mergePaths(this.modulePaths, child.modulePaths); + } + } + } + + onStartup(extension) { + return Promise.all(this.children.map(child => child.onStartup(extension))); + } + + getModule(name) { + for (let child of this.children) { + if (child.modules.has(name)) { + return child.modules.get(name); + } + } + } + + loadModule(name) { + for (let child of this.children) { + if (child.modules.has(name)) { + return child.loadModule(name); + } + } + } + + asyncLoadModule(name) { + for (let child of this.children) { + if (child.modules.has(name)) { + return child.asyncLoadModule(name); + } + } + } +} + +defineLazyGetter(MultiAPIManager.prototype, "schema", function () { + let bases = this.children.map(child => child.schema); + + // All API manager schema roots should derive from the global schema root, + // so it doesn't need its own entry. + if (bases[bases.length - 1] === lazy.Schemas) { + bases.pop(); + } + + if (bases.length === 1) { + bases = bases[0]; + } + return new lazy.SchemaRoot(bases, new Map()); +}); + +export function LocaleData(data) { + this.defaultLocale = data.defaultLocale; + this.selectedLocale = data.selectedLocale; + this.locales = data.locales || new Map(); + this.warnedMissingKeys = new Set(); + + // Map(locale-name -> Map(message-key -> localized-string)) + // + // Contains a key for each loaded locale, each of which is a + // Map of message keys to their localized strings. + this.messages = data.messages || new Map(); + + if (data.builtinMessages) { + this.messages.set(this.BUILTIN, data.builtinMessages); + } +} + +LocaleData.prototype = { + // Representation of the object to send to content processes. This + // should include anything the content process might need. + serialize() { + return { + defaultLocale: this.defaultLocale, + selectedLocale: this.selectedLocale, + messages: this.messages, + locales: this.locales, + }; + }, + + BUILTIN: "@@BUILTIN_MESSAGES", + + has(locale) { + return this.messages.has(locale); + }, + + // https://developer.chrome.com/extensions/i18n + localizeMessage(message, substitutions = [], options = {}) { + let defaultOptions = { + defaultValue: "", + cloneScope: null, + }; + + let locales = this.availableLocales; + if (options.locale) { + locales = new Set( + [this.BUILTIN, options.locale, this.defaultLocale].filter(locale => + this.messages.has(locale) + ) + ); + } + + options = Object.assign(defaultOptions, options); + + // Message names are case-insensitive, so normalize them to lower-case. + message = message.toLowerCase(); + for (let locale of locales) { + let messages = this.messages.get(locale); + if (messages.has(message)) { + let str = messages.get(message); + + if (!str.includes("$")) { + return str; + } + + if (!Array.isArray(substitutions)) { + substitutions = [substitutions]; + } + + let replacer = (matched, index, dollarSigns) => { + if (index) { + // This is not quite Chrome-compatible. Chrome consumes any number + // of digits following the $, but only accepts 9 substitutions. We + // accept any number of substitutions. + index = parseInt(index, 10) - 1; + return index in substitutions ? substitutions[index] : ""; + } + // For any series of contiguous `$`s, the first is dropped, and + // the rest remain in the output string. + return dollarSigns; + }; + return str.replace(/\$(?:([1-9]\d*)|(\$+))/g, replacer); + } + } + + // Check for certain pre-defined messages. + if (message == "@@ui_locale") { + return this.uiLocale; + } else if (message.startsWith("@@bidi_")) { + let rtl = Services.locale.isAppLocaleRTL; + + if (message == "@@bidi_dir") { + return rtl ? "rtl" : "ltr"; + } else if (message == "@@bidi_reversed_dir") { + return rtl ? "ltr" : "rtl"; + } else if (message == "@@bidi_start_edge") { + return rtl ? "right" : "left"; + } else if (message == "@@bidi_end_edge") { + return rtl ? "left" : "right"; + } + } + + if (!this.warnedMissingKeys.has(message)) { + let error = `Unknown localization message ${message}`; + if (options.cloneScope) { + error = new options.cloneScope.Error(error); + } + Cu.reportError(error); + this.warnedMissingKeys.add(message); + } + return options.defaultValue; + }, + + // Localize a string, replacing all |__MSG_(.*)__| tokens with the + // matching string from the current locale, as determined by + // |this.selectedLocale|. + // + // This may not be called before calling either |initLocale| or + // |initAllLocales|. + localize(str, locale = this.selectedLocale) { + if (!str) { + return str; + } + + return str.replace(/__MSG_([A-Za-z0-9@_]+?)__/g, (matched, message) => { + return this.localizeMessage(message, [], { + locale, + defaultValue: matched, + }); + }); + }, + + // Validates the contents of a locale JSON file, normalizes the + // messages into a Map of message key -> localized string pairs. + addLocale(locale, messages, extension) { + let result = new Map(); + + let isPlainObject = obj => + obj && + typeof obj === "object" && + ChromeUtils.getClassName(obj) === "Object"; + + // Chrome does not document the semantics of its localization + // system very well. It handles replacements by pre-processing + // messages, replacing |$[a-zA-Z0-9@_]+$| tokens with the value of their + // replacements. Later, it processes the resulting string for + // |$[0-9]| replacements. + // + // Again, it does not document this, but it accepts any number + // of sequential |$|s, and replaces them with that number minus + // 1. It also accepts |$| followed by any number of sequential + // digits, but refuses to process a localized string which + // provides more than 9 substitutions. + if (!isPlainObject(messages)) { + extension.packagingError(`Invalid locale data for ${locale}`); + return result; + } + + for (let key of Object.keys(messages)) { + let msg = messages[key]; + + if (!isPlainObject(msg) || typeof msg.message != "string") { + extension.packagingError( + `Invalid locale message data for ${locale}, message ${JSON.stringify( + key + )}` + ); + continue; + } + + // Substitutions are case-insensitive, so normalize all of their names + // to lower-case. + let placeholders = new Map(); + if ("placeholders" in msg && isPlainObject(msg.placeholders)) { + for (let key of Object.keys(msg.placeholders)) { + placeholders.set(key.toLowerCase(), msg.placeholders[key]); + } + } + + let replacer = (match, name) => { + let replacement = placeholders.get(name.toLowerCase()); + if (isPlainObject(replacement) && "content" in replacement) { + return replacement.content; + } + return ""; + }; + + let value = msg.message.replace(/\$([A-Za-z0-9@_]+)\$/g, replacer); + + // Message names are also case-insensitive, so normalize them to lower-case. + result.set(key.toLowerCase(), value); + } + + this.messages.set(locale, result); + return result; + }, + + get acceptLanguages() { + let result = Services.prefs.getComplexValue( + "intl.accept_languages", + Ci.nsIPrefLocalizedString + ).data; + return result.split(/\s*,\s*/g); + }, + + get uiLocale() { + return Services.locale.appLocaleAsBCP47; + }, + + get availableLocales() { + const locales = [this.BUILTIN, this.selectedLocale, this.defaultLocale]; + const value = new Set(locales.filter(locale => this.messages.has(locale))); + return redefineGetter(this, "availableLocales", value); + }, +}; + +/** + * This is a generic class for managing event listeners. + * + * @example + * new EventManager({ + * context, + * name: "api.subAPI", + * register: fire => { + * let listener = (...) => { + * // Fire any listeners registered with addListener. + * fire.async(arg1, arg2); + * }; + * // Register the listener. + * SomehowRegisterListener(listener); + * return () => { + * // Return a way to unregister the listener. + * SomehowUnregisterListener(listener); + * }; + * } + * }).api() + * + * The result is an object with addListener, removeListener, and + * hasListener methods. `context` is an add-on scope (either an + * ExtensionContext in the chrome process or ExtensionContext in a + * content process). + */ +class EventManager { + /* + * A persistent event must provide module and name. Additionally the + * module must implement primeListeners in the ExtensionAPI class. + * + * A startup blocking event must also add the startupBlocking flag in + * ext-toolkit.json or ext-browser.json. + * + * Listeners synchronously added from a background extension context + * will be persisted, for a persistent background script only the + * "startup blocking" events will be persisted. + * + * EventManager instances created in a child process can't persist any listener. + * + * @param {object} params + * Parameters that control this EventManager. + * @param {BaseContext} params.context + * An object representing the extension instance using this event. + * @param {string} params.module + * The API module name, required for persistent events. + * @param {string} params.event + * The API event name, required for persistent events. + * @param {ExtensionAPI} params.extensionApi + * The API intance. If the API uses the ExtensionAPIPersistent class, some simplification is + * possible by passing the api (self or this) and the internal register function will be used. + * @param {string} [params.name] + * A name used only for debugging. If not provided, name is built from module and event. + * @param {functon} params.register + * A function called whenever a new listener is added. + * @param {boolean} [params.inputHandling=false] + * If true, the "handling user input" flag is set while handlers + * for this event are executing. + */ + constructor(params) { + let { + context, + module, + event, + name, + register, + extensionApi, + inputHandling = false, + resetIdleOnEvent = true, + } = params; + this.context = context; + this.module = module; + this.event = event; + this.name = name; + this.register = register; + this.inputHandling = inputHandling; + this.resetIdleOnEvent = resetIdleOnEvent; + + const isBackgroundParent = + this.context.envType === "addon_parent" && + this.context.isBackgroundContext; + + // TODO(Bug 1844041): ideally we should restrict resetIdleOnEvent to + // EventManager instances that belongs to the event page, but along + // with that we should consider if calling sendMessage from an event + // page should also reset idle timer, and so in the shorter term + // here we are allowing listeners from other extension pages to + // also reset the idle timer. + const isAddonContext = ["addon_parent", "addon_child"].includes( + this.context.envType + ); + + // Avoid resetIdleOnEvent overhead by only consider it when applicable. + if (!isAddonContext || context.extension.persistentBackground) { + this.resetIdleOnEvent = false; + } + + if (!name) { + this.name = `${module}.${event}`; + } + + if (!this.register && extensionApi instanceof ExtensionAPIPersistent) { + this.register = (fire, ...params) => { + return extensionApi.registerEventListener( + { context, event, fire }, + params + ); + }; + } + if (!this.register) { + throw new Error( + `EventManager requires register method for ${this.name}.` + ); + } + + this.canPersistEvents = module && event && isBackgroundParent; + + if (this.canPersistEvents) { + let { extension } = context; + if (extension.persistentBackground) { + // Persistent backgrounds will only persist startup blocking APIs. + let api_module = extension.apiManager.getModule(this.module); + if (!api_module?.startupBlocking) { + this.canPersistEvents = false; + } + } else { + // Event pages will persist all APIs that implement primeListener. + // The api is already loaded so this does not have performance effect. + let api = extension.apiManager.getAPI( + this.module, + extension, + "addon_parent" + ); + + // If the api doesn't implement primeListener we do not persist the events. + if (!api?.primeListener) { + this.canPersistEvents = false; + } + } + } + + this.unregister = new Map(); + this.remove = new Map(); + } + + /* + * Information about listeners to persistent events is associated with + * the extension to which they belong. Any extension thas has such + * listeners has a property called `persistentListeners` that is a + * 3-level Map: + * + * - the first 2 keys are the module name (e.g., webRequest) + * and the name of the event within the module (e.g., onBeforeRequest). + * + * - the third level of the map is used to track multiple listeners for + * the same event, these listeners are distinguished by the extra arguments + * passed to addListener() + * + * - for quick lookups, the key to the third Map is the result of calling + * uneval() on the array of extra arguments. + * + * - the value stored in the Map or persistent listeners we keep in memory + * is a plain object with: + * - a property called `params` that is the original (ie, not uneval()ed) + * extra arguments to addListener() + * - and a property called `listeners` that is an array of plain object + * each representing a listener to be primed and a `primeId` autoincremented + * integer that represents each of the primed listeners that belongs to the + * group listeners with the same set of extra params. + * - a `nextPrimeId` property keeps track of the numeric primeId that should + * be assigned to new persistent listeners added for the same event and + * same set of extra params. + * + * For a primed listener (i.e., the stub listener created during browser startup + * before the extension background page is started, and after an event page is + * suspended on idle), the object will be later populated (by the callers of + * EventManager.primeListeners) with an additional `primed` property that serves + * as a placeholder listener, collecting all events that got emitted while the + * background page was not yet started, and eventually replaced by a callback + * registered from the extension code, once the background page scripts have been + * executed (or dropped if the background page scripts do not register the same + * listener anymore). + * + * @param {Extension} extension + * @returns {boolean} True if the extension had any persistent listeners. + */ + static _initPersistentListeners(extension) { + if (extension.persistentListeners) { + return !!extension.persistentListeners.size; + } + + let listeners = new DefaultMap(() => new DefaultMap(() => new Map())); + extension.persistentListeners = listeners; + + let persistentListeners = extension.startupData?.persistentListeners; + if (!persistentListeners) { + return false; + } + + let found = false; + for (let [module, savedModuleEntry] of Object.entries( + persistentListeners + )) { + for (let [event, savedEventEntry] of Object.entries(savedModuleEntry)) { + for (let paramList of savedEventEntry) { + /* Before Bug 1795801 (Firefox < 113) each entry was related to a listener + * registered with a different set of extra params (and so only one listener + * could be persisted for the same set of extra params) + * + * After Bug 1795801 (Firefox >= 113) each entry still represents a listener + * registered for that event, but multiple listeners registered with the same + * set of extra params will be captured as multiple entries in the + * paramsList array. + * + * NOTE: persisted listeners are stored in the startupData part of the Addon DB + * and are expected to be preserved across Firefox and Addons upgrades and downgrades + * (unlike the WebExtensions startupCache data which is cleared when Firefox or the + * addon is updated) and so we are taking special care about forward and backward + * compatibility of the persistentListeners on-disk format: + * + * - forward compatibility: when this new version of this startupData loading logic + * is loading the old persistentListeners on-disk format: + * - on the first run only one listener will be primed for each of the extra params + * recorded in the startupData (same as in older Firefox versions) + * and Bug 1795801 will still be hit, but once the background + * context is started once the startupData will be updated to + * include each of the listeners (indipendently if the set of + * extra params is the same as another listener already been + * persisted). + * - after the first run, all listeners will be primed separately, even if the extra + * params are the same as other listeners already primed, and so + * each of the listener will receive the pending events collected + * by their related primed listener and Bug 1795801 not to be hit anymore. + * + * - backward compatibility: when the old version of this startupData loading logic + * (https://searchfox.org/mozilla-central/rev/cd2121e7d8/toolkit/components/extensions/ExtensionCommon.jsm#2360-2371) + * is loading the new persistentListeners on-disk format, the last + * entry with the same set of extra params will be eventually overwritting the + * entry for another primed listener with the same extra params, Bug 1795801 will still + * be hit, but no actual change in behavior is expected. + */ + let key = uneval(paramList); + const eventEntry = listeners.get(module).get(event); + + if (eventEntry.has(key)) { + const keyEntry = eventEntry.get(key); + let primeId = keyEntry.nextPrimeId; + keyEntry.listeners.push({ primeId }); + keyEntry.nextPrimeId++; + } else { + eventEntry.set(key, { + params: paramList, + nextPrimeId: 1, + listeners: [{ primeId: 0 }], + }); + } + found = true; + } + } + } + return found; + } + + // Extract just the information needed at startup for all persistent + // listeners, and arrange for it to be saved. This should be called + // whenever the set of persistent listeners for an extension changes. + static _writePersistentListeners(extension) { + let startupListeners = {}; + for (let [module, moduleEntry] of extension.persistentListeners) { + startupListeners[module] = {}; + for (let [event, eventEntry] of moduleEntry) { + // Turn the per-event entries from the format they are being kept + // in memory: + // + // [ + // { params: paramList1, listeners: [listener1, listener2, ...] }, + // { params: paramList2, listeners: [listener3, listener3, ...] }, + // ... + // ] + // + // into the format used for storing them on disk (in the startupData), + // which is an array of the params for each listener (with the param list + // included as many times as many listeners are persisted for the same + // set of params): + // + // [paramList1, paramList1, ..., paramList2, paramList2, ...] + // + // This format will also work as expected on older Firefox versions where + // only one listener was being persisted for each set of params. + startupListeners[module][event] = Array.from( + eventEntry.values() + ).flatMap(keyEntry => keyEntry.listeners.map(() => keyEntry.params)); + } + } + + extension.startupData.persistentListeners = startupListeners; + extension.saveStartupData(); + } + + // Set up "primed" event listeners for any saved event listeners + // in an extension's startup data. + // This function is only called during browser startup, it stores details + // about all primed listeners in the extension's persistentListeners Map. + static primeListeners(extension, isInStartup = false) { + if (!EventManager._initPersistentListeners(extension)) { + return; + } + + for (let [module, moduleEntry] of extension.persistentListeners) { + // If we're in startup, we only want to continue attempting to prime a + // subset of events that should be startup blocking. + if (isInStartup) { + let api_module = extension.apiManager.getModule(module); + if (!api_module.startupBlocking) { + continue; + } + } + + let api = extension.apiManager.getAPI(module, extension, "addon_parent"); + + // If an extension is upgraded and a permission, such as webRequest, is + // removed, we will have been called but the API is no longer available. + if (!api?.primeListener) { + // The runtime module no longer implements primed listeners, drop them. + extension.persistentListeners.delete(module); + EventManager._writePersistentListeners(extension); + continue; + } + for (let [event, eventEntry] of moduleEntry) { + for (let [key, { params, listeners }] of eventEntry) { + for (let listener of listeners) { + // Reset the `listener.added` flag by setting it to `false` while + // re-priming the listeners because the event page has suspended + // and the previous converted listener is no longer listening. + const listenerWasAdded = listener.added; + listener.added = false; + listener.params = params; + let primed = { pendingEvents: [] }; + + let fireEvent = (...args) => + new Promise((resolve, reject) => { + if (!listener.primed) { + reject( + new Error( + `primed listener ${module}.${event} not re-registered` + ) + ); + return; + } + primed.pendingEvents.push({ args, resolve, reject }); + extension.emit("background-script-event"); + }); + + let fire = { + wakeup: () => extension.wakeupBackground(), + sync: fireEvent, + async: fireEvent, + // fire.async for ProxyContextParent is already not cloning. + raw: fireEvent, + }; + + try { + let handler = api.primeListener( + event, + fire, + listener.params, + isInStartup + ); + if (handler) { + listener.primed = primed; + Object.assign(primed, handler); + } + } catch (e) { + Cu.reportError( + `Error priming listener ${module}.${event}: ${e} :: ${e.stack}` + ); + // Force this listener to be cleared. + listener.error = true; + } + + // If an attempt to prime a listener failed, ensure it is cleared now. + // If a module is a startup blocking module, not all listeners may + // get primed during early startup. For that reason, we don't clear + // persisted listeners during early startup. At the end of background + // execution any listeners that were not renewed will be cleared. + // + // TODO(Bug 1797474): consider priming runtime.onStartup and + // avoid to special handling it here. + if ( + listener.error || + (!isInStartup && + !( + (`${module}.${event}` === "runtime.onStartup" && + listenerWasAdded) || + listener.primed + )) + ) { + EventManager.clearPersistentListener( + extension, + module, + event, + key, + listener.primeId + ); + } + } + } + } + } + } + + /** + * This is called as a result of background script startup-finished and shutdown. + * + * After startup, it removes any remaining primed listeners. These exist if the + * listener was not renewed during startup. In this case the persisted listener + * data is also removed. + * + * During shutdown, care should be taken to set clearPersistent to false. + * persisted listener data should NOT be cleared during shutdown. + * + * @param {Extension} extension + * @param {boolean} clearPersistent whether the persisted listener data should be cleared. + */ + static clearPrimedListeners(extension, clearPersistent = true) { + if (!extension.persistentListeners) { + return; + } + + for (let [module, moduleEntry] of extension.persistentListeners) { + for (let [event, eventEntry] of moduleEntry) { + for (let [key, { listeners }] of eventEntry) { + for (let listener of listeners) { + let { primed, added, primeId } = listener; + // When a primed listener is added or renewed during initial + // background execution we set an added flag. If it was primed + // when added, primed is set to null. + if (added) { + continue; + } + + if (primed) { + // When a primed listener was not renewed, primed will still be truthy. + // These need to be cleared on shutdown (important for event pages), but + // we only clear the persisted listener data after the startup of a background. + // Release any pending events and unregister the primed handler. + listener.primed = null; + + for (let evt of primed.pendingEvents) { + evt.reject(new Error("listener not re-registered")); + } + primed.unregister(); + } + + // Clear any persisted events that were not renewed, should typically + // only be done at the end of the background page load. + if (clearPersistent) { + EventManager.clearPersistentListener( + extension, + module, + event, + key, + primeId + ); + } + } + } + } + } + } + + // Record the fact that there is a listener for the given event in + // the given extension. `args` is an Array containing any extra + // arguments that were passed to addListener(). + static savePersistentListener(extension, module, event, args = []) { + EventManager._initPersistentListeners(extension); + let key = uneval(args); + const eventEntry = extension.persistentListeners.get(module).get(event); + + let primeId; + if (!eventEntry.has(key)) { + // when writing, only args are written, other properties are dropped + primeId = 0; + eventEntry.set(key, { + params: args, + listeners: [{ added: true, primeId }], + nextPrimeId: 1, + }); + } else { + const keyEntry = eventEntry.get(key); + primeId = keyEntry.nextPrimeId; + keyEntry.listeners.push({ added: true, primeId }); + keyEntry.nextPrimeId = primeId + 1; + } + + EventManager._writePersistentListeners(extension); + return [module, event, key, primeId]; + } + + // Remove the record for the given event listener from the extension's + // startup data. `key` must be a string, the result of calling uneval() + // on the array of extra arguments originally passed to addListener(). + static clearPersistentListener( + extension, + module, + event, + key = uneval([]), + primeId = undefined + ) { + let eventEntry = extension.persistentListeners.get(module).get(event); + + let keyEntry = eventEntry.get(key); + + if (primeId != undefined && keyEntry) { + keyEntry.listeners = keyEntry.listeners.filter( + listener => listener.primeId !== primeId + ); + } + + if (primeId == undefined || keyEntry?.listeners.length === 0) { + eventEntry.delete(key); + if (eventEntry.size == 0) { + let moduleEntry = extension.persistentListeners.get(module); + moduleEntry.delete(event); + if (moduleEntry.size == 0) { + extension.persistentListeners.delete(module); + } + } + } + + EventManager._writePersistentListeners(extension); + } + + addListener(callback, ...args) { + if (this.unregister.has(callback)) { + return; + } + this.context.logActivity("api_call", `${this.name}.addListener`, { args }); + + let shouldFire = () => { + if (this.context.unloaded) { + dump(`${this.name} event fired after context unloaded.\n`); + } else if (!this.context.active) { + dump(`${this.name} event fired while context is inactive.\n`); + } else if (this.unregister.has(callback)) { + return true; + } + return false; + }; + + let { extension } = this.context; + const resetIdle = () => { + if (this.resetIdleOnEvent) { + extension?.emit("background-script-reset-idle", { + reason: "event", + eventName: this.name, + }); + } + }; + + let fire = { + // Bug 1754866 fire.sync doesn't match documentation. + sync: (...args) => { + if (shouldFire()) { + resetIdle(); + let result = this.context.applySafe(callback, args); + this.context.logActivity("api_event", this.name, { args, result }); + return result; + } + }, + async: (...args) => { + return Promise.resolve().then(() => { + if (shouldFire()) { + resetIdle(); + let result = this.context.applySafe(callback, args); + this.context.logActivity("api_event", this.name, { args, result }); + return result; + } + }); + }, + raw: (...args) => { + if (!shouldFire()) { + throw new Error("Called raw() on unloaded/inactive context"); + } + resetIdle(); + let result = Reflect.apply(callback, null, args); + this.context.logActivity("api_event", this.name, { args, result }); + return result; + }, + asyncWithoutClone: (...args) => { + return Promise.resolve().then(() => { + if (shouldFire()) { + resetIdle(); + let result = this.context.applySafeWithoutClone(callback, args); + this.context.logActivity("api_event", this.name, { args, result }); + return result; + } + }); + }, + }; + + let { module, event } = this; + + let unregister = null; + let recordStartupData = false; + + // If this is a persistent event, check for a listener that was already + // created during startup. If there is one, use it and don't create a + // new one. + if (this.canPersistEvents) { + // Once a background is started, listenerPromises is set to null. At + // that point, we stop recording startup data. + recordStartupData = !!this.context.listenerPromises; + + let key = uneval(args); + EventManager._initPersistentListeners(extension); + let keyEntry = extension.persistentListeners + .get(module) + .get(event) + .get(key); + + // Get the first persistent listener which matches the module, event and extra arguments + // and not added back by the extension yet, the persistent listener found may be either + // primed or not (in particular API Events that belongs to APIs that should not be blocking + // startup may have persistent listeners that are not primed during the first execution + // of the background context happening as part of the applications startup, whereas they + // will be primed when the background context will be suspended on the idle timeout). + let listener = keyEntry?.listeners.find(listener => !listener.added); + if (listener) { + // During startup only a subset of persisted listeners are primed. As + // well, each API determines whether to prime a specific listener. + let { primed } = listener; + if (primed) { + listener.primed = null; + + primed.convert(fire, this.context); + unregister = primed.unregister; + + for (let evt of primed.pendingEvents) { + evt.resolve(fire.async(...evt.args)); + } + } + listener.added = true; + + recordStartupData = false; + this.remove.set(callback, () => { + EventManager.clearPersistentListener( + extension, + module, + event, + uneval(args), + listener.primeId + ); + }); + } + } + + if (!unregister) { + unregister = this.register(fire, ...args); + } + + this.unregister.set(callback, unregister); + this.context.callOnClose(this); + + // If this is a new listener for a persistent event, record + // the details for subsequent startups. + if (recordStartupData) { + const [, , , /* _module */ /* _event */ /* _key */ primeId] = + EventManager.savePersistentListener(extension, module, event, args); + this.remove.set(callback, () => { + EventManager.clearPersistentListener( + extension, + module, + event, + uneval(args), + primeId + ); + }); + } + } + + removeListener(callback, clearPersistentListener = true) { + if (!this.unregister.has(callback)) { + return; + } + this.context.logActivity("api_call", `${this.name}.removeListener`, { + args: [], + }); + + let unregister = this.unregister.get(callback); + this.unregister.delete(callback); + try { + unregister(); + } catch (e) { + Cu.reportError(e); + } + + if (clearPersistentListener && this.remove.has(callback)) { + let cleanup = this.remove.get(callback); + this.remove.delete(callback); + cleanup(); + } + + if (this.unregister.size == 0) { + this.context.forgetOnClose(this); + } + } + + hasListener(callback) { + return this.unregister.has(callback); + } + + revoke() { + for (let callback of this.unregister.keys()) { + this.removeListener(callback, false); + } + } + + close() { + this.revoke(); + } + + api() { + return { + addListener: (...args) => this.addListener(...args), + removeListener: (...args) => this.removeListener(...args), + hasListener: (...args) => this.hasListener(...args), + setUserInput: this.inputHandling, + [lazy.Schemas.REVOKE]: () => this.revoke(), + }; + } +} + +// Simple API for event listeners where events never fire. +function ignoreEvent(context, name) { + return { + addListener: function (callback) { + let id = context.extension.id; + let frame = Components.stack.caller; + let msg = `In add-on ${id}, attempting to use listener "${name}", which is unimplemented.`; + let scriptError = Cc["@mozilla.org/scripterror;1"].createInstance( + Ci.nsIScriptError + ); + scriptError.init( + msg, + frame.filename, + null, + frame.lineNumber, + frame.columnNumber, + Ci.nsIScriptError.warningFlag, + "content javascript" + ); + Services.console.logMessage(scriptError); + }, + removeListener: function (callback) {}, + hasListener: function (callback) {}, + }; +} + +const stylesheetMap = new DefaultMap(url => { + let uri = Services.io.newURI(url); + return lazy.styleSheetService.preloadSheet( + uri, + // Note: keep in sync with ext-browser-content.js. This used to be + // AGENT_SHEET, but changed to AUTHOR_SHEET, see bug 1873024. + lazy.styleSheetService.AUTHOR_SHEET + ); +}); + +/** + * Updates the in-memory representation of extension host permissions, i.e. + * policy.allowedOrigins. + * + * @param {WebExtensionPolicy} policy + * A policy. All MatchPattern instances in policy.allowedOrigins are + * expected to have been constructed with ignorePath: true. + * @param {string[]} origins + * A list of already-normalized origins, equivalent to using the + * MatchPattern constructor with ignorePath: true. + * @param {boolean} isAdd + * Whether to add instead of removing the host permissions. + */ +function updateAllowedOrigins(policy, origins, isAdd) { + if (!origins.length) { + // Nothing to modify. + return; + } + let patternMap = new Map(); + for (let pattern of policy.allowedOrigins.patterns) { + patternMap.set(pattern.pattern, pattern); + } + if (!isAdd) { + for (let origin of origins) { + patternMap.delete(origin); + } + } else { + // In the parent process, policy.extension.restrictSchemes is available. + // In the content process, we need to check the mozillaAddons permission, + // which is only available if approved by the parent. + const restrictSchemes = + policy.extension?.restrictSchemes ?? + policy.hasPermission("mozillaAddons"); + for (let origin of origins) { + if (patternMap.has(origin)) { + continue; + } + patternMap.set( + origin, + new MatchPattern(origin, { restrictSchemes, ignorePath: true }) + ); + } + } + // patternMap contains only MatchPattern instances, so we don't need to set + // the options parameter (with restrictSchemes, etc.) since that is only used + // if the input is a string. + policy.allowedOrigins = new MatchPatternSet(Array.from(patternMap.values())); +} + +export var ExtensionCommon = { + BaseContext, + CanOfAPIs, + EventManager, + ExtensionAPI, + ExtensionAPIPersistent, + EventEmitter, + LocalAPIImplementation, + LocaleData, + NoCloneSpreadArgs, + SchemaAPIInterface, + SchemaAPIManager, + SpreadArgs, + checkLoadURI, + checkLoadURL, + defineLazyGetter, + redefineGetter, + getConsole, + ignoreEvent, + instanceOf, + makeWidgetId, + normalizeTime, + runSafeSyncWithoutClone, + stylesheetMap, + updateAllowedOrigins, + withHandlingUserInput, + + MultiAPIManager, + LazyAPIManager, +}; diff --git a/toolkit/components/extensions/ExtensionContent.sys.mjs b/toolkit/components/extensions/ExtensionContent.sys.mjs new file mode 100644 index 0000000000..131d555bf0 --- /dev/null +++ b/toolkit/components/extensions/ExtensionContent.sys.mjs @@ -0,0 +1,1308 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionProcessScript: + "resource://gre/modules/ExtensionProcessScript.sys.mjs", + ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs", + LanguageDetector: + "resource://gre/modules/translation/LanguageDetector.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", + WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "styleSheetService", + "@mozilla.org/content/style-sheet-service;1", + "nsIStyleSheetService" +); + +const Timer = Components.Constructor( + "@mozilla.org/timer;1", + "nsITimer", + "initWithCallback" +); + +const ScriptError = Components.Constructor( + "@mozilla.org/scripterror;1", + "nsIScriptError", + "initWithWindowID" +); + +import { + ExtensionChild, + ExtensionActivityLogChild, +} from "resource://gre/modules/ExtensionChild.sys.mjs"; +import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { + DefaultMap, + DefaultWeakMap, + getInnerWindowID, + promiseDocumentIdle, + promiseDocumentLoaded, + promiseDocumentReady, +} = ExtensionUtils; + +const { + BaseContext, + CanOfAPIs, + SchemaAPIManager, + defineLazyGetter, + redefineGetter, + runSafeSyncWithoutClone, +} = ExtensionCommon; + +const { BrowserExtensionContent, ChildAPIManager, Messenger } = ExtensionChild; + +ChromeUtils.defineLazyGetter(lazy, "isContentScriptProcess", () => { + return ( + Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_CONTENT || + !WebExtensionPolicy.useRemoteWebExtensions || + // Thunderbird still loads some content in the parent process. + AppConstants.MOZ_APP_NAME == "thunderbird" + ); +}); + +var DocumentManager; + +const CATEGORY_EXTENSION_SCRIPTS_CONTENT = "webextension-scripts-content"; + +var apiManager = new (class extends SchemaAPIManager { + constructor() { + super("content", lazy.Schemas); + this.initialized = false; + } + + lazyInit() { + if (!this.initialized) { + this.initialized = true; + this.initGlobal(); + for (let { value } of Services.catMan.enumerateCategory( + CATEGORY_EXTENSION_SCRIPTS_CONTENT + )) { + this.loadScript(value); + } + } + } +})(); + +const SCRIPT_EXPIRY_TIMEOUT_MS = 5 * 60 * 1000; +const SCRIPT_CLEAR_TIMEOUT_MS = 5 * 1000; + +const CSS_EXPIRY_TIMEOUT_MS = 30 * 60 * 1000; +const CSSCODE_EXPIRY_TIMEOUT_MS = 10 * 60 * 1000; + +const scriptCaches = new WeakSet(); +const sheetCacheDocuments = new DefaultWeakMap(() => new WeakSet()); + +class CacheMap extends DefaultMap { + constructor(timeout, getter, extension) { + super(getter); + + this.expiryTimeout = timeout; + + scriptCaches.add(this); + + // This ensures that all the cached scripts and stylesheets are deleted + // from the cache and the xpi is no longer actively used. + // See Bug 1435100 for rationale. + extension.once("shutdown", () => { + this.clear(-1); + }); + } + + get(url) { + let promise = super.get(url); + + promise.lastUsed = Date.now(); + if (promise.timer) { + promise.timer.cancel(); + } + promise.timer = Timer( + this.delete.bind(this, url), + this.expiryTimeout, + Ci.nsITimer.TYPE_ONE_SHOT + ); + + return promise; + } + + delete(url) { + if (this.has(url)) { + super.get(url).timer.cancel(); + } + + return super.delete(url); + } + + clear(timeout = SCRIPT_CLEAR_TIMEOUT_MS) { + let now = Date.now(); + for (let [url, promise] of this.entries()) { + // Delete the entry if expired or if clear has been called with timeout -1 + // (which is used to force the cache to clear all the entries, e.g. when the + // extension is shutting down). + if (timeout === -1 || now - promise.lastUsed >= timeout) { + this.delete(url); + } + } + } +} + +class ScriptCache extends CacheMap { + constructor(options, extension) { + super( + SCRIPT_EXPIRY_TIMEOUT_MS, + url => { + let promise = ChromeUtils.compileScript(url, options); + promise.then(script => { + promise.script = script; + }); + return promise; + }, + extension + ); + } +} + +/** + * Shared base class for the two specialized CSS caches: + * CSSCache (for the "url"-based stylesheets) and CSSCodeCache + * (for the stylesheet defined by plain CSS content as a string). + */ +class BaseCSSCache extends CacheMap { + constructor(expiryTimeout, defaultConstructor, extension) { + super(expiryTimeout, defaultConstructor, extension); + } + + addDocument(key, document) { + sheetCacheDocuments.get(this.get(key)).add(document); + } + + deleteDocument(key, document) { + sheetCacheDocuments.get(this.get(key)).delete(document); + } + + delete(key) { + if (this.has(key)) { + let promise = this.get(key); + + // Never remove a sheet from the cache if it's still being used by a + // document. Rule processors can be shared between documents with the + // same preloaded sheet, so we only lose by removing them while they're + // still in use. + let docs = ChromeUtils.nondeterministicGetWeakSetKeys( + sheetCacheDocuments.get(promise) + ); + if (docs.length) { + return; + } + } + + return super.delete(key); + } +} + +/** + * Cache of the preloaded stylesheet defined by url. + */ +class CSSCache extends BaseCSSCache { + constructor(sheetType, extension) { + super( + CSS_EXPIRY_TIMEOUT_MS, + url => { + let uri = Services.io.newURI(url); + return lazy.styleSheetService + .preloadSheetAsync(uri, sheetType) + .then(sheet => { + return { url, sheet }; + }); + }, + extension + ); + } +} + +/** + * Cache of the preloaded stylesheet defined by plain CSS content as a string, + * the key of the cached stylesheet is the hash of its "CSSCode" string. + */ +class CSSCodeCache extends BaseCSSCache { + constructor(sheetType, extension) { + super( + CSSCODE_EXPIRY_TIMEOUT_MS, + hash => { + if (!this.has(hash)) { + // Do not allow the getter to be used to lazily create the cached stylesheet, + // the cached CSSCode stylesheet has to be explicitly set. + throw new Error( + "Unexistent cached cssCode stylesheet: " + Error().stack + ); + } + + return super.get(hash); + }, + extension + ); + + // Store the preferred sheetType (used to preload the expected stylesheet type in + // the addCSSCode method). + this.sheetType = sheetType; + } + + addCSSCode(hash, cssCode) { + if (this.has(hash)) { + // This cssCode have been already cached, no need to create it again. + return; + } + // The `webext=style` portion is added metadata to help us distinguish + // different kinds of data URL loads that are triggered with the + // SystemPrincipal. It shall be removed with bug 1699425. + const uri = Services.io.newURI( + "data:text/css;extension=style;charset=utf-8," + + encodeURIComponent(cssCode) + ); + const value = lazy.styleSheetService + .preloadSheetAsync(uri, this.sheetType) + .then(sheet => { + return { sheet, uri }; + }); + + super.set(hash, value); + } +} + +defineLazyGetter( + BrowserExtensionContent.prototype, + "staticScripts", + function () { + return new ScriptCache({ hasReturnValue: false }, this); + } +); + +defineLazyGetter( + BrowserExtensionContent.prototype, + "dynamicScripts", + function () { + return new ScriptCache({ hasReturnValue: true }, this); + } +); + +defineLazyGetter(BrowserExtensionContent.prototype, "userCSS", function () { + return new CSSCache(Ci.nsIStyleSheetService.USER_SHEET, this); +}); + +defineLazyGetter(BrowserExtensionContent.prototype, "authorCSS", function () { + return new CSSCache(Ci.nsIStyleSheetService.AUTHOR_SHEET, this); +}); + +// These two caches are similar to the above but specialized to cache the cssCode +// using an hash computed from the cssCode string as the key (instead of the generated data +// URI which can be pretty long for bigger injected cssCode). +defineLazyGetter(BrowserExtensionContent.prototype, "userCSSCode", function () { + return new CSSCodeCache(Ci.nsIStyleSheetService.USER_SHEET, this); +}); + +defineLazyGetter( + BrowserExtensionContent.prototype, + "authorCSSCode", + function () { + return new CSSCodeCache(Ci.nsIStyleSheetService.AUTHOR_SHEET, this); + } +); + +// Represents a content script. +class Script { + /** + * @param {BrowserExtensionContent} extension + * @param {WebExtensionContentScript|object} matcher + * An object with a "matchesWindowGlobal" method and content script + * execution details. This is usually a plain WebExtensionContentScript + * except when the script is run via `tabs.executeScript`. In this + * case, the object may have some extra properties: + * wantReturnValue, removeCSS, cssOrigin, jsCode + */ + constructor(extension, matcher) { + this.scriptType = "content_script"; + this.extension = extension; + this.matcher = matcher; + + this.runAt = this.matcher.runAt; + this.js = this.matcher.jsPaths; + this.css = this.matcher.cssPaths.slice(); + this.cssCodeHash = null; + + this.removeCSS = this.matcher.removeCSS; + this.cssOrigin = this.matcher.cssOrigin; + + this.cssCache = + extension[this.cssOrigin === "user" ? "userCSS" : "authorCSS"]; + this.cssCodeCache = + extension[this.cssOrigin === "user" ? "userCSSCode" : "authorCSSCode"]; + this.scriptCache = + extension[matcher.wantReturnValue ? "dynamicScripts" : "staticScripts"]; + + /** @type {WeakSet} A set of documents injected into. */ + this.injectedInto = new WeakSet(); + + if (matcher.wantReturnValue) { + this.compileScripts(); + this.loadCSS(); + } + } + + get requiresCleanup() { + return !this.removeCSS && (!!this.css.length || this.cssCodeHash); + } + + async addCSSCode(cssCode) { + if (!cssCode) { + return; + } + + // Store the hash of the cssCode. + const buffer = await crypto.subtle.digest( + "SHA-1", + new TextEncoder().encode(cssCode) + ); + this.cssCodeHash = String.fromCharCode(...new Uint16Array(buffer)); + + // Cache and preload the cssCode stylesheet. + this.cssCodeCache.addCSSCode(this.cssCodeHash, cssCode); + } + + compileScripts() { + return this.js.map(url => this.scriptCache.get(url)); + } + + loadCSS() { + return this.css.map(url => this.cssCache.get(url)); + } + + preload() { + this.loadCSS(); + this.compileScripts(); + } + + cleanup(window) { + if (this.requiresCleanup) { + if (window) { + let { windowUtils } = window; + + let type = + this.cssOrigin === "user" + ? windowUtils.USER_SHEET + : windowUtils.AUTHOR_SHEET; + + for (let url of this.css) { + this.cssCache.deleteDocument(url, window.document); + + if (!window.closed) { + runSafeSyncWithoutClone( + windowUtils.removeSheetUsingURIString, + url, + type + ); + } + } + + const { cssCodeHash } = this; + + if (cssCodeHash && this.cssCodeCache.has(cssCodeHash)) { + if (!window.closed) { + this.cssCodeCache.get(cssCodeHash).then(({ uri }) => { + runSafeSyncWithoutClone(windowUtils.removeSheet, uri, type); + }); + } + this.cssCodeCache.deleteDocument(cssCodeHash, window.document); + } + } + + // Clear any sheets that were kept alive past their timeout as + // a result of living in this document. + this.cssCodeCache.clear(CSSCODE_EXPIRY_TIMEOUT_MS); + this.cssCache.clear(CSS_EXPIRY_TIMEOUT_MS); + } + } + + matchesWindowGlobal(windowGlobal, ignorePermissions) { + return this.matcher.matchesWindowGlobal(windowGlobal, ignorePermissions); + } + + async injectInto(window, reportExceptions = true) { + if ( + !lazy.isContentScriptProcess || + this.injectedInto.has(window.document) + ) { + return; + } + this.injectedInto.add(window.document); + + let context = this.extension.getContext(window); + for (let script of this.matcher.jsPaths) { + context.logActivity(this.scriptType, script, { + url: window.location.href, + }); + } + + try { + if (this.runAt === "document_end") { + await promiseDocumentReady(window.document); + } else if (this.runAt === "document_idle") { + await Promise.race([ + promiseDocumentIdle(window), + promiseDocumentLoaded(window.document), + ]); + } + + return this.inject(context, reportExceptions); + } catch (e) { + return Promise.reject(context.normalizeError(e)); + } + } + + /** + * Tries to inject this script into the given window and sandbox, if + * there are pending operations for the window's current load state. + * + * @param {ContentScriptContextChild} context + * The content script context into which to inject the scripts. + * @param {boolean} reportExceptions + * Defaults to true and reports any exception directly to the console + * and no exception will be thrown out of this function. + * @returns {Promise} + * Resolves to the last value in the evaluated script, when + * execution is complete. + */ + async inject(context, reportExceptions = true) { + DocumentManager.lazyInit(); + if (this.requiresCleanup) { + context.addScript(this); + } + + const { cssCodeHash } = this; + + let cssPromise; + if (this.css.length || cssCodeHash) { + let window = context.contentWindow; + let { windowUtils } = window; + + let type = + this.cssOrigin === "user" + ? windowUtils.USER_SHEET + : windowUtils.AUTHOR_SHEET; + + if (this.removeCSS) { + for (let url of this.css) { + this.cssCache.deleteDocument(url, window.document); + + runSafeSyncWithoutClone( + windowUtils.removeSheetUsingURIString, + url, + type + ); + } + + if (cssCodeHash && this.cssCodeCache.has(cssCodeHash)) { + const { uri } = await this.cssCodeCache.get(cssCodeHash); + this.cssCodeCache.deleteDocument(cssCodeHash, window.document); + + runSafeSyncWithoutClone(windowUtils.removeSheet, uri, type); + } + } else { + cssPromise = Promise.all(this.loadCSS()).then(sheets => { + let window = context.contentWindow; + if (!window) { + return; + } + + for (let { url, sheet } of sheets) { + this.cssCache.addDocument(url, window.document); + + runSafeSyncWithoutClone(windowUtils.addSheet, sheet, type); + } + }); + + if (cssCodeHash) { + cssPromise = cssPromise.then(async () => { + const { sheet } = await this.cssCodeCache.get(cssCodeHash); + this.cssCodeCache.addDocument(cssCodeHash, window.document); + + runSafeSyncWithoutClone(windowUtils.addSheet, sheet, type); + }); + } + + // We're loading stylesheets via the stylesheet service, which means + // that the normal mechanism for blocking layout and onload for pending + // stylesheets aren't in effect (since there's no document to block). So + // we need to do something custom here, similar to what we do for + // scripts. Blocking parsing is overkill, since we really just want to + // block layout and onload. But we have an API to do the former and not + // the latter, so we do it that way. This hopefully isn't a performance + // problem since there are no network loads involved, and since we cache + // the stylesheets on first load. We should fix this up if it does becomes + // a problem. + if (this.css.length) { + context.contentWindow.document.blockParsing(cssPromise, { + blockScriptCreated: false, + }); + } + } + } + + let scripts = this.getCompiledScripts(context); + if (scripts instanceof Promise) { + scripts = await scripts; + } + + // Make sure we've injected any related CSS before we run content scripts. + await cssPromise; + + let result; + + const { extension } = context; + + // The evaluations below may throw, in which case the promise will be + // automatically rejected. + lazy.ExtensionTelemetry.contentScriptInjection.stopwatchStart( + extension, + context + ); + try { + for (let script of scripts) { + result = script.executeInGlobal(context.cloneScope, { + reportExceptions, + }); + } + + if (this.matcher.jsCode) { + result = Cu.evalInSandbox( + this.matcher.jsCode, + context.cloneScope, + "latest", + "sandbox eval code", + 1 + ); + } + } finally { + lazy.ExtensionTelemetry.contentScriptInjection.stopwatchFinish( + extension, + context + ); + } + + return result; + } + + /** + * Get the compiled scripts (if they are already precompiled and cached) or a promise which resolves + * to the precompiled scripts (once they have been compiled and cached). + * + * @param {ContentScriptContextChild} context + * The document to block the parsing on, if the scripts are not yet precompiled and cached. + * + * @returns {Array | Promise>} + * Returns an array of preloaded scripts if they are already available, or a promise which + * resolves to the array of the preloaded scripts once they are precompiled and cached. + */ + getCompiledScripts(context) { + let scriptPromises = this.compileScripts(); + let scripts = scriptPromises.map(promise => promise.script); + + // If not all scripts are already available in the cache, block + // parsing and wait all promises to resolve. + if (!scripts.every(script => script)) { + let promise = Promise.all(scriptPromises); + + // If there is any syntax error, the script promises will be rejected. + // + // Notify the exception directly to the console so that it can + // be displayed in the web console by flagging the error with the right + // innerWindowID. + for (const p of scriptPromises) { + p.catch(error => { + Services.console.logMessage( + new ScriptError( + `${error.name}: ${error.message}`, + error.fileName, + null, + error.lineNumber, + error.columnNumber, + Ci.nsIScriptError.errorFlag, + "content javascript", + context.innerWindowID + ) + ); + }); + } + + // If we're supposed to inject at the start of the document load, + // and we haven't already missed that point, block further parsing + // until the scripts have been loaded. + const { document } = context.contentWindow; + if ( + this.runAt === "document_start" && + document.readyState !== "complete" + ) { + document.blockParsing(promise, { blockScriptCreated: false }); + } + + return promise; + } + + return scripts; + } +} + +// Represents a user script. +class UserScript extends Script { + /** + * @param {BrowserExtensionContent} extension + * @param {WebExtensionContentScript|object} matcher + * An object with a "matchesWindowGlobal" method and content script + * execution details. + */ + constructor(extension, matcher) { + super(extension, matcher); + this.scriptType = "user_script"; + + // This is an opaque object that the extension provides, it is associated to + // the particular userScript and it is passed as a parameter to the custom + // userScripts APIs defined by the extension. + this.scriptMetadata = matcher.userScriptOptions.scriptMetadata; + this.apiScriptURL = + extension.manifest.user_scripts && + extension.manifest.user_scripts.api_script; + + // Add the apiScript to the js scripts to compile. + if (this.apiScriptURL) { + this.js = [this.apiScriptURL].concat(this.js); + } + + // WeakMap + this.sandboxes = new DefaultWeakMap(context => { + return this.createSandbox(context); + }); + } + + async inject(context) { + DocumentManager.lazyInit(); + + let scripts = this.getCompiledScripts(context); + if (scripts instanceof Promise) { + scripts = await scripts; + } + + let apiScript, sandboxScripts; + + if (this.apiScriptURL) { + [apiScript, ...sandboxScripts] = scripts; + } else { + sandboxScripts = scripts; + } + + // Load and execute the API script once per context. + if (apiScript) { + context.executeAPIScript(apiScript); + } + + let userScriptSandbox = this.sandboxes.get(context); + + context.callOnClose({ + close: () => { + // Destroy the userScript sandbox when the related ContentScriptContextChild instance + // is being closed. + this.sandboxes.delete(context); + Cu.nukeSandbox(userScriptSandbox); + }, + }); + + // Notify listeners subscribed to the userScripts.onBeforeScript API event, + // to allow extension API script to provide its custom APIs to the userScript. + if (apiScript) { + context.userScriptsEvents.emit( + "on-before-script", + this.scriptMetadata, + userScriptSandbox + ); + } + + for (let script of sandboxScripts) { + script.executeInGlobal(userScriptSandbox); + } + } + + createSandbox(context) { + const { contentWindow } = context; + const contentPrincipal = contentWindow.document.nodePrincipal; + const ssm = Services.scriptSecurityManager; + + let principal; + if (contentPrincipal.isSystemPrincipal) { + principal = ssm.createNullPrincipal(contentPrincipal.originAttributes); + } else { + principal = [contentPrincipal]; + } + + const sandbox = Cu.Sandbox(principal, { + sandboxName: `User Script registered by ${this.extension.policy.debugName}`, + sandboxPrototype: contentWindow, + sameZoneAs: contentWindow, + wantXrays: true, + wantGlobalProperties: ["XMLHttpRequest", "fetch", "WebSocket"], + originAttributes: contentPrincipal.originAttributes, + metadata: { + "inner-window-id": context.innerWindowID, + addonId: this.extension.policy.id, + }, + }); + + return sandbox; + } +} + +var contentScripts = new DefaultWeakMap(matcher => { + const extension = lazy.ExtensionProcessScript.extensions.get( + matcher.extension + ); + + if ("userScriptOptions" in matcher) { + return new UserScript(extension, matcher); + } + + return new Script(extension, matcher); +}); + +/** + * An execution context for semi-privileged extension content scripts. + * + * This is the child side of the ContentScriptContextParent class + * defined in ExtensionParent.jsm. + */ +class ContentScriptContextChild extends BaseContext { + constructor(extension, contentWindow) { + super("content_child", extension); + + this.setContentWindow(contentWindow); + + let frameId = lazy.WebNavigationFrames.getFrameId(contentWindow); + this.frameId = frameId; + + this.browsingContextId = contentWindow.docShell.browsingContext.id; + + this.scripts = []; + + let contentPrincipal = contentWindow.document.nodePrincipal; + let ssm = Services.scriptSecurityManager; + + // Copy origin attributes from the content window origin attributes to + // preserve the user context id. + let attrs = contentPrincipal.originAttributes; + let extensionPrincipal = ssm.createContentPrincipal( + this.extension.baseURI, + attrs + ); + + this.isExtensionPage = contentPrincipal.equals(extensionPrincipal); + + if (this.isExtensionPage) { + // This is an iframe with content script API enabled and its principal + // should be the contentWindow itself. We create a sandbox with the + // contentWindow as principal and with X-rays disabled because it + // enables us to create the APIs object in this sandbox object and then + // copying it into the iframe's window. See bug 1214658. + this.sandbox = Cu.Sandbox(contentWindow, { + sandboxName: `Web-Accessible Extension Page ${extension.policy.debugName}`, + sandboxPrototype: contentWindow, + sameZoneAs: contentWindow, + wantXrays: false, + isWebExtensionContentScript: true, + }); + } else { + let principal; + if (contentPrincipal.isSystemPrincipal) { + // Make sure we don't hand out the system principal by accident. + // Also make sure that the null principal has the right origin attributes. + principal = ssm.createNullPrincipal(attrs); + } else { + principal = [contentPrincipal, extensionPrincipal]; + } + // This metadata is required by the Developer Tools, in order for + // the content script to be associated with both the extension and + // the tab holding the content page. + let metadata = { + "inner-window-id": this.innerWindowID, + addonId: extensionPrincipal.addonId, + }; + + let isMV2 = extension.manifestVersion == 2; + let wantGlobalProperties; + if (isMV2) { + // In MV2, fetch/XHR support cross-origin requests. + // WebSocket was also included to avoid CSP effects (bug 1676024). + wantGlobalProperties = ["XMLHttpRequest", "fetch", "WebSocket"]; + } else { + // In MV3, fetch/XHR have the same capabilities as the web page. + wantGlobalProperties = []; + } + this.sandbox = Cu.Sandbox(principal, { + metadata, + sandboxName: `Content Script ${extension.policy.debugName}`, + sandboxPrototype: contentWindow, + sameZoneAs: contentWindow, + wantXrays: true, + isWebExtensionContentScript: true, + wantExportHelpers: true, + wantGlobalProperties, + originAttributes: attrs, + }); + + // Preserve a copy of the original Error and Promise globals from the sandbox object, + // which are used in the WebExtensions internals (before any content script code had + // any chance to redefine them). + this.cloneScopePromise = this.sandbox.Promise; + this.cloneScopeError = this.sandbox.Error; + + if (isMV2) { + // Preserve a copy of the original window's XMLHttpRequest and fetch + // in a content object (fetch is manually binded to the window + // to prevent it from raising a TypeError because content object is not + // a real window). + Cu.evalInSandbox( + ` + this.content = { + XMLHttpRequest: window.XMLHttpRequest, + fetch: window.fetch.bind(window), + WebSocket: window.WebSocket, + }; + + window.JSON = JSON; + window.XMLHttpRequest = XMLHttpRequest; + window.fetch = fetch; + window.WebSocket = WebSocket; + `, + this.sandbox + ); + } else { + // The sandbox's JSON API can deal with values from the sandbox and the + // contentWindow, but window.JSON cannot (and it could potentially be + // spoofed by the web page). jQuery.parseJSON relies on window.JSON. + Cu.evalInSandbox("window.JSON = JSON;", this.sandbox); + } + } + + Object.defineProperty(this, "principal", { + value: Cu.getObjectPrincipal(this.sandbox), + enumerable: true, + configurable: true, + }); + + this.url = contentWindow.location.href; + + lazy.Schemas.exportLazyGetter( + this.sandbox, + "browser", + () => this.chromeObj + ); + lazy.Schemas.exportLazyGetter(this.sandbox, "chrome", () => this.chromeObj); + + // Keep track if the userScript API script has been already executed in this context + // (e.g. because there are more then one UserScripts that match the related webpage + // and so the UserScript apiScript has already been executed). + this.hasUserScriptAPIs = false; + + // A lazy created EventEmitter related to userScripts-specific events. + defineLazyGetter(this, "userScriptsEvents", () => { + return new ExtensionCommon.EventEmitter(); + }); + } + + injectAPI() { + if (!this.isExtensionPage) { + throw new Error("Cannot inject extension API into non-extension window"); + } + + // This is an iframe with content script API enabled (See Bug 1214658) + lazy.Schemas.exportLazyGetter( + this.contentWindow, + "browser", + () => this.chromeObj + ); + lazy.Schemas.exportLazyGetter( + this.contentWindow, + "chrome", + () => this.chromeObj + ); + } + + async logActivity(type, name, data) { + ExtensionActivityLogChild.log(this, type, name, data); + } + + get cloneScope() { + return this.sandbox; + } + + async executeAPIScript(apiScript) { + // Execute the UserScript apiScript only once per context (e.g. more then one UserScripts + // match the same webpage and the apiScript has already been executed). + if (apiScript && !this.hasUserScriptAPIs) { + this.hasUserScriptAPIs = true; + apiScript.executeInGlobal(this.cloneScope); + } + } + + addScript(script) { + if (script.requiresCleanup) { + this.scripts.push(script); + } + } + + close() { + super.unload(); + + // Cleanup the scripts even if the contentWindow have been destroyed. + for (let script of this.scripts) { + script.cleanup(this.contentWindow); + } + + if (this.contentWindow) { + // Overwrite the content script APIs with an empty object if the APIs objects are still + // defined in the content window (See Bug 1214658). + if (this.isExtensionPage) { + Cu.createObjectIn(this.contentWindow, { defineAs: "browser" }); + Cu.createObjectIn(this.contentWindow, { defineAs: "chrome" }); + } + } + Cu.nukeSandbox(this.sandbox); + + this.sandbox = null; + } + + get childManager() { + apiManager.lazyInit(); + let can = new CanOfAPIs(this, apiManager, {}); + let childManager = new ChildAPIManager(this, this.messageManager, can, { + envType: "content_parent", + url: this.url, + }); + this.callOnClose(childManager); + return redefineGetter(this, "childManager", childManager); + } + + get chromeObj() { + let chromeObj = Cu.createObjectIn(this.sandbox); + this.childManager.inject(chromeObj); + return redefineGetter(this, "chromeObj", chromeObj); + } + + get messenger() { + return redefineGetter(this, "messenger", new Messenger(this)); + } +} + +// Responsible for creating ExtensionContexts and injecting content +// scripts into them when new documents are created. +DocumentManager = { + // Map[windowId -> Map[ExtensionChild -> ContentScriptContextChild]] + contexts: new Map(), + + initialized: false, + + lazyInit() { + if (this.initialized) { + return; + } + this.initialized = true; + + Services.obs.addObserver(this, "inner-window-destroyed"); + Services.obs.addObserver(this, "memory-pressure"); + }, + + uninit() { + Services.obs.removeObserver(this, "inner-window-destroyed"); + Services.obs.removeObserver(this, "memory-pressure"); + }, + + observers: { + "inner-window-destroyed"(subject, topic, data) { + let windowId = subject.QueryInterface(Ci.nsISupportsPRUint64).data; + + // Close any existent content-script context for the destroyed window. + if (this.contexts.has(windowId)) { + let extensions = this.contexts.get(windowId); + for (let context of extensions.values()) { + context.close(); + } + + this.contexts.delete(windowId); + } + }, + "memory-pressure"(subject, topic, data) { + let timeout = data === "heap-minimize" ? 0 : undefined; + + for (let cache of ChromeUtils.nondeterministicGetWeakSetKeys( + scriptCaches + )) { + cache.clear(timeout); + } + }, + }, + + /** + * @param {object} subject + * @param {keyof typeof DocumentManager.observers} topic + * @param {any} data + */ + observe(subject, topic, data) { + this.observers[topic].call(this, subject, topic, data); + }, + + shutdownExtension(extension) { + for (let extensions of this.contexts.values()) { + let context = extensions.get(extension); + if (context) { + context.close(); + extensions.delete(extension); + } + } + }, + + getContexts(window) { + let winId = getInnerWindowID(window); + + let extensions = this.contexts.get(winId); + if (!extensions) { + extensions = new Map(); + this.contexts.set(winId, extensions); + } + + return extensions; + }, + + // For test use only. + getContext(extensionId, window) { + for (let [extension, context] of this.getContexts(window)) { + if (extension.id === extensionId) { + return context; + } + } + }, + + getContentScriptGlobals(window) { + let extensions = this.contexts.get(getInnerWindowID(window)); + + if (extensions) { + return Array.from(extensions.values(), ctx => ctx.sandbox); + } + + return []; + }, + + initExtensionContext(extension, window) { + extension.getContext(window).injectAPI(); + }, +}; + +export var ExtensionContent = { + BrowserExtensionContent, + + contentScripts, + + shutdownExtension(extension) { + DocumentManager.shutdownExtension(extension); + }, + + // This helper is exported to be integrated in the devtools RDP actors, + // that can use it to retrieve the existent WebExtensions ContentScripts + // of a target window and be able to show the ContentScripts source in the + // DevTools Debugger panel. + getContentScriptGlobals(window) { + return DocumentManager.getContentScriptGlobals(window); + }, + + initExtensionContext(extension, window) { + DocumentManager.initExtensionContext(extension, window); + }, + + getContext(extension, window) { + let extensions = DocumentManager.getContexts(window); + + let context = extensions.get(extension); + if (!context) { + context = new ContentScriptContextChild(extension, window); + extensions.set(extension, context); + } + return context; + }, + + // For test use only. + getContextByExtensionId(extensionId, window) { + return DocumentManager.getContext(extensionId, window); + }, + + async handleDetectLanguage({ windows }) { + let wgc = WindowGlobalChild.getByInnerWindowId(windows[0]); + let doc = wgc.browsingContext.window.document; + await promiseDocumentReady(doc); + + // The CLD2 library can analyze HTML, but that uses more memory, and + // emscripten can't shrink its heap, so we use plain text instead. + let encoder = Cu.createDocumentEncoder("text/plain"); + encoder.init(doc, "text/plain", Ci.nsIDocumentEncoder.SkipInvisibleContent); + + let result = await lazy.LanguageDetector.detectLanguage({ + language: + doc.documentElement.getAttribute("xml:lang") || + doc.documentElement.getAttribute("lang") || + doc.contentLanguage || + null, + tld: doc.location.hostname.match(/[a-z]*$/)[0], + text: encoder.encodeToStringWithMaxLength(60 * 1024), + encoding: doc.characterSet, + }); + return result.language === "un" ? "und" : result.language; + }, + + // Activate MV3 content scripts in all same-origin frames for this tab. + handleActivateScripts({ options, windows }) { + let policy = WebExtensionPolicy.getByID(options.id); + + // Order content scripts by run_at timing. + let runAt = { document_start: [], document_end: [], document_idle: [] }; + for (let matcher of policy.contentScripts) { + runAt[matcher.runAt].push(this.contentScripts.get(matcher)); + } + + // If we got here, checks in TabManagerBase.activateScripts assert: + // 1) this is a MV3 extension, with Origin Controls, + // 2) with a host permission (or content script) for the tab's top origin, + // 3) and that host permission hasn't been granted yet. + + // We treat the action click as implicit user's choice to activate the + // extension on the current site, so we can safely run (matching) content + // scripts in all sameOriginWithTop frames while ignoring host permission. + + let { browsingContext } = WindowGlobalChild.getByInnerWindowId(windows[0]); + for (let bc of browsingContext.getAllBrowsingContextsInSubtree()) { + let wgc = bc.currentWindowContext.windowGlobalChild; + if (wgc?.sameOriginWithTop) { + // This is TOCTOU safe: if a frame navigated after same-origin check, + // wgc.isClosed would be true and .matchesWindowGlobal() would fail. + const runScript = cs => { + if (cs.matchesWindowGlobal(wgc, /* ignorePermissions */ true)) { + return cs.injectInto(bc.window); + } + }; + + // Inject all matching content scripts in proper run_at order. + Promise.all(runAt.document_start.map(runScript)) + .then(() => Promise.all(runAt.document_end.map(runScript))) + .then(() => Promise.all(runAt.document_idle.map(runScript))); + } + } + }, + + // Used to executeScript, insertCSS and removeCSS. + async handleActorExecute({ options, windows }) { + let policy = WebExtensionPolicy.getByID(options.extensionId); + // `WebExtensionContentScript` uses `MozDocumentMatcher::Matches` to ensure + // that a script can be run in a document. That requires either `frameId` + // or `allFrames` to be set. When `frameIds` (plural) is used, we force + // `allFrames` to be `true` in order to match any frame. This is OK because + // `executeInWin()` below looks up the window for the given `frameIds` + // immediately before `script.injectInto()`. Due to this, we won't run + // scripts in windows with non-matching `frameId`, despite `allFrames` + // being set to `true`. + if (options.frameIds) { + options.allFrames = true; + } + let matcher = new WebExtensionContentScript(policy, options); + + Object.assign(matcher, { + wantReturnValue: options.wantReturnValue, + removeCSS: options.removeCSS, + cssOrigin: options.cssOrigin, + jsCode: options.jsCode, + }); + let script = contentScripts.get(matcher); + + // Add the cssCode to the script, so that it can be converted into a cached URL. + await script.addCSSCode(options.cssCode); + delete options.cssCode; + + const executeInWin = innerId => { + let wg = WindowGlobalChild.getByInnerWindowId(innerId); + if (wg?.isCurrentGlobal && script.matchesWindowGlobal(wg)) { + let bc = wg.browsingContext; + + return { + frameId: bc.parent ? bc.id : 0, + // Disable exception reporting directly to the console + // in order to pass the exceptions back to the callsite. + promise: script.injectInto(bc.window, false), + }; + } + }; + + let promisesWithFrameIds = windows.map(executeInWin).filter(obj => obj); + + let result = await Promise.all( + promisesWithFrameIds.map(async ({ frameId, promise }) => { + if (!options.returnResultsWithFrameIds) { + return promise; + } + + try { + const result = await promise; + + return { frameId, result }; + } catch (error) { + return { frameId, error }; + } + }) + ).catch( + // This is useful when we do not return results/errors with frame IDs in + // the promises above. + e => Promise.reject({ message: e.message }) + ); + + try { + // Check if the result can be structured-cloned before sending back. + return Cu.cloneInto(result, this); + } catch (e) { + let path = options.jsPaths.slice(-1)[0] ?? ""; + let message = `Script '${path}' result is non-structured-clonable data`; + return Promise.reject({ message, fileName: path }); + } + }, +}; + +/** + * Child side of the ExtensionContent process actor, handles some tabs.* APIs. + */ +export class ExtensionContentChild extends JSProcessActorChild { + receiveMessage({ name, data }) { + if (!lazy.isContentScriptProcess) { + return; + } + switch (name) { + case "DetectLanguage": + return ExtensionContent.handleDetectLanguage(data); + case "Execute": + return ExtensionContent.handleActorExecute(data); + case "ActivateScripts": + return ExtensionContent.handleActivateScripts(data); + } + } +} diff --git a/toolkit/components/extensions/ExtensionDNR.sys.mjs b/toolkit/components/extensions/ExtensionDNR.sys.mjs new file mode 100644 index 0000000000..cd01d52b72 --- /dev/null +++ b/toolkit/components/extensions/ExtensionDNR.sys.mjs @@ -0,0 +1,2436 @@ +/* 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/. */ + +// Each extension that uses DNR has one RuleManager. All registered RuleManagers +// are checked whenever a network request occurs. Individual extensions may +// occasionally modify their rules (e.g. via the updateSessionRules API). +const gRuleManagers = []; + +/** + * Whenever a request occurs, the rules of each RuleManager are matched against + * the request to determine the final action to take. The RequestEvaluator class + * is responsible for evaluating rules, and its behavior is described below. + * + * Short version: + * Find the highest-priority rule that matches the given request. If the + * request is not canceled, all matching allowAllRequests and modifyHeaders + * actions are returned. + * + * Longer version: + * Unless stated otherwise, the explanation below describes the behavior within + * an extension. + * An extension can specify rules, optionally in multiple rulesets. The ability + * to have multiple ruleset exists to support bulk updates of rules. Rulesets + * are NOT independent - rules from different rulesets can affect each other. + * + * When multiple rules match, the order between rules are defined as follows: + * - Ruleset precedence: session > dynamic > static (order from manifest.json). + * - Rules in ruleset precedence: ordered by rule.id, lowest (numeric) ID first. + * - Across all rules+rulesets: highest rule.priority (default 1) first, + * action precedence if rule priority are the same. + * + * The primary documented way for extensions to describe precedence is by + * specifying rule.priority. Between same-priority rules, their precedence is + * dependent on the rule action. The ruleset/rule ID precedence is only used to + * have a defined ordering if multiple rules have the same priority+action. + * + * Rule actions have the following order of precedence and meaning: + * - "allow" can be used to ignore other same-or-lower-priority rules. + * - "allowAllRequests" (for main_frame / sub_frame resourceTypes only) has the + * same effect as allow, but also applies to (future) subresource loads in + * the document (including descendant frames) generated from the request. + * - "block" cancels the matched request. + * - "upgradeScheme" upgrades the scheme of the request. + * - "redirect" redirects the request. + * - "modifyHeaders" rewrites request/response headers. + * + * The matched rules are evaluated in two passes: + * 1. findMatchingRules(): + * Find the highest-priority rule(s), and choose the action with the highest + * precedence (across all rulesets, any action except modifyHeaders). + * This also accounts for any allowAllRequests from an ancestor frame. + * + * 2. getMatchingModifyHeadersRules(): + * Find matching rules with the "modifyHeaders" action, minus ignored rules. + * Reaching this step implies that the request was not canceled, so either + * the first step did not yield a rule, or the rule action is "allow" or + * "allowAllRequests" (i.e. ignore same-or-lower-priority rules). + * + * If an extension does not have sufficient permissions for the action, the + * resulting action is ignored. + * + * The above describes the evaluation within one extension. When a sequence of + * (multiple) extensions is given, they may return conflicting actions in the + * first pass. This is resolved by choosing the action with the following order + * of precedence, in RequestEvaluator.evaluateRequest(): + * - block + * - redirect / upgradeScheme + * - allow / allowAllRequests + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionDNRLimits: "resource://gre/modules/ExtensionDNRLimits.sys.mjs", + ExtensionDNRStore: "resource://gre/modules/ExtensionDNRStore.sys.mjs", + WebRequest: "resource://gre/modules/WebRequest.sys.mjs", +}); + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { ExtensionError } = ExtensionUtils; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "gMatchRequestsFromOtherExtensions", + "extensions.dnr.match_requests_from_other_extensions", + false +); + +// As documented above: +// Ruleset precedence: session > dynamic > static (order from manifest.json). +const PRECEDENCE_SESSION_RULESET = 1; +const PRECEDENCE_DYNAMIC_RULESET = 2; +const PRECEDENCE_STATIC_RULESETS_BASE = 3; + +// The RuleCondition class represents a rule's "condition" type as described in +// schemas/declarative_net_request.json. This class exists to allow the JS +// engine to use one Shape for all Rule instances. +class RuleCondition { + #compiledUrlFilter; + #compiledRegexFilter; + + constructor(cond) { + this.urlFilter = cond.urlFilter; + this.regexFilter = cond.regexFilter; + this.isUrlFilterCaseSensitive = cond.isUrlFilterCaseSensitive; + this.initiatorDomains = cond.initiatorDomains; + this.excludedInitiatorDomains = cond.excludedInitiatorDomains; + this.requestDomains = cond.requestDomains; + this.excludedRequestDomains = cond.excludedRequestDomains; + this.resourceTypes = cond.resourceTypes; + this.excludedResourceTypes = cond.excludedResourceTypes; + this.requestMethods = cond.requestMethods; + this.excludedRequestMethods = cond.excludedRequestMethods; + this.domainType = cond.domainType; + this.tabIds = cond.tabIds; + this.excludedTabIds = cond.excludedTabIds; + } + + // See CompiledUrlFilter for documentation. + urlFilterMatches(requestDataForUrlFilter) { + if (!this.#compiledUrlFilter) { + // eslint-disable-next-line no-use-before-define + this.#compiledUrlFilter = new CompiledUrlFilter( + this.urlFilter, + this.isUrlFilterCaseSensitive + ); + } + return this.#compiledUrlFilter.matchesRequest(requestDataForUrlFilter); + } + + // Used for testing regexFilter matches in RuleEvaluator.#matchRuleCondition + // and to get redirect URL from regexSubstitution in applyRegexSubstitution. + getCompiledRegexFilter() { + return this.#compiledRegexFilter; + } + + // RuleValidator compiles regexFilter before this Rule class is instantiated. + // To avoid unnecessarily compiling it again, the result is assigned here. + setCompiledRegexFilter(compiledRegexFilter) { + this.#compiledRegexFilter = compiledRegexFilter; + } +} + +export class Rule { + constructor(rule) { + this.id = rule.id; + this.priority = rule.priority; + this.condition = new RuleCondition(rule.condition); + this.action = rule.action; + } + + // The precedence of rules within an extension. This method is frequently + // used during the first pass of the RequestEvaluator. + actionPrecedence() { + switch (this.action.type) { + case "allow": + return 1; // Highest precedence. + case "allowAllRequests": + return 2; + case "block": + return 3; + case "upgradeScheme": + return 4; + case "redirect": + return 5; + case "modifyHeaders": + return 6; + default: + throw new Error(`Unexpected action type: ${this.action.type}`); + } + } + + isAllowOrAllowAllRequestsAction() { + const type = this.action.type; + return type === "allow" || type === "allowAllRequests"; + } +} + +class Ruleset { + /** + * @param {string} rulesetId - extension-defined ruleset ID. + * @param {integer} rulesetPrecedence + * @param {Rule[]} rules - extension-defined rules + * @param {RuleManager} ruleManager - owner of this ruleset. + */ + constructor(rulesetId, rulesetPrecedence, rules, ruleManager) { + this.id = rulesetId; + this.rulesetPrecedence = rulesetPrecedence; + this.rules = rules; + // For use by MatchedRule. + this.ruleManager = ruleManager; + } +} + +/** + * @param {string} uriQuery - The query of a nsIURI to transform. + * @param {object} queryTransform - The value of the + * Rule.action.redirect.transform.queryTransform property as defined in + * declarative_net_request.json. + * @returns {string} The uriQuery with the queryTransform applied to it. + */ +function applyQueryTransform(uriQuery, queryTransform) { + // URLSearchParams cannot be applied to the full query string, because that + // API formats the full query string using form-urlencoding. But the input + // may be in a different format. So we try to only modify matched params. + + function urlencode(s) { + // Encode in application/x-www-form-urlencoded format. + // The only JS API to do that is URLSearchParams. encodeURIComponent is not + // the same, it differs in how it handles " " ("%20") and "!'()~" (raw). + // But urlencoded space should be "+" and the latter be "%21%27%28%29%7E". + return new URLSearchParams({ s }).toString().slice(2); + } + if (!uriQuery.length && !queryTransform.addOrReplaceParams) { + // Nothing to do. + return ""; + } + const removeParamsSet = new Set(queryTransform.removeParams?.map(urlencode)); + const addParams = (queryTransform.addOrReplaceParams || []).map(orig => ({ + normalizedKey: urlencode(orig.key), + orig, + })); + const finalParams = []; + if (uriQuery.length) { + for (let part of uriQuery.split("&")) { + let key = part.split("=", 1)[0]; + if (removeParamsSet.has(key)) { + continue; + } + let i = addParams.findIndex(p => p.normalizedKey === key); + if (i !== -1) { + // Replace found param with the key-value from addOrReplaceParams. + finalParams.push(`${key}=${urlencode(addParams[i].orig.value)}`); + // Omit param so that a future search for the same key can find the next + // specified key-value pair, if any. And to prevent the already-used + // key-value pairs from being appended after the loop. + addParams.splice(i, 1); + } else { + finalParams.push(part); + } + } + } + // Append remaining, unused key-value pairs. + for (let { normalizedKey, orig } of addParams) { + if (!orig.replaceOnly) { + finalParams.push(`${normalizedKey}=${urlencode(orig.value)}`); + } + } + return finalParams.length ? `?${finalParams.join("&")}` : ""; +} + +/** + * @param {nsIURI} uri - Usually a http(s) URL. + * @param {object} transform - The value of the Rule.action.redirect.transform + * property as defined in declarative_net_request.json. + * @returns {nsIURI} uri - The new URL. + * @throws if the transformation is invalid. + */ +function applyURLTransform(uri, transform) { + let mut = uri.mutate(); + if (transform.scheme) { + // Note: declarative_net_request.json only allows http(s)/moz-extension:. + mut.setScheme(transform.scheme); + if (uri.port !== -1 || transform.port) { + // If the URI contains a port or transform.port was specified, the default + // port is significant. So we must set it in that case. + if (transform.scheme === "https") { + mut.QueryInterface(Ci.nsIStandardURLMutator).setDefaultPort(443); + } else if (transform.scheme === "http") { + mut.QueryInterface(Ci.nsIStandardURLMutator).setDefaultPort(80); + } + } + } + if (transform.username != null) { + mut.setUsername(transform.username); + } + if (transform.password != null) { + mut.setPassword(transform.password); + } + if (transform.host != null) { + mut.setHost(transform.host); + } + if (transform.port != null) { + // The caller ensures that transform.port is a string consisting of digits + // only. When it is an empty string, it should be cleared (-1). + mut.setPort(transform.port || -1); + } + if (transform.path != null) { + mut.setFilePath(transform.path); + } + if (transform.query != null) { + mut.setQuery(transform.query); + } else if (transform.queryTransform) { + mut.setQuery(applyQueryTransform(uri.query, transform.queryTransform)); + } + if (transform.fragment != null) { + mut.setRef(transform.fragment); + } + return mut.finalize(); +} + +/** + * @param {nsIURI} uri - Usually a http(s) URL. + * @param {MatchedRule} matchedRule - The matched rule with a regexFilter + * condition and regexSubstitution action. + * @returns {nsIURI} The new URL derived from the regexSubstitution combined + * with capturing group from regexFilter applied to the input uri. + * @throws if the resulting URL is an invalid redirect target. + */ +function applyRegexSubstitution(uri, matchedRule) { + const rule = matchedRule.rule; + const extension = matchedRule.ruleManager.extension; + const regexSubstitution = rule.action.redirect.regexSubstitution; + const compiledRegexFilter = rule.condition.getCompiledRegexFilter(); + // This method being called implies that regexFilter matched, so |matches| is + // always non-null, i.e. an array of string/undefined values. + const matches = compiledRegexFilter.exec(uri.spec); + + let redirectUrl = regexSubstitution.replace(/\\(.)/g, (_, char) => { + // #checkActionRedirect ensures that every \ is followed by a \ or digit. + return char === "\\" ? char : matches[char] ?? ""; + }); + + // Throws if the URL is invalid: + let redirectUri; + try { + redirectUri = Services.io.newURI(redirectUrl); + } catch (e) { + throw new Error( + `Extension ${extension.id} tried to redirect to an invalid URL: ${redirectUrl}` + ); + } + if (!extension.checkLoadURI(redirectUri, { dontReportErrors: true })) { + throw new Error( + `Extension ${extension.id} may not redirect to: ${redirectUrl}` + ); + } + return redirectUri; +} + +/** + * An urlFilter is a string pattern to match a canonical http(s) URL. + * urlFilter matches anywhere in the string, unless an anchor is present: + * - ||... ("Domain name anchor") - domain or subdomain starts with ... + * - |... ("Left anchor") - URL starts with ... + * - ...| ("Right anchor") - URL ends with ... + * + * Other than the anchors, the following special characters exist: + * - ^ = end of URL, or any char except: alphanum _ - . % ("Separator") + * - * = any number of characters ("Wildcard") + * + * Ambiguous cases (undocumented but actual Chrome behavior): + * - Plain "||" is a domain name anchor, not left + empty + right anchor. + * - "^" repeated at end of pattern: "^" matches end of URL only once. + * - "^|" at end of pattern: "^" is allowed to match end of URL. + * + * Implementation details: + * - CompiledUrlFilter's constructor (+#initializeUrlFilter) extracts the + * actual urlFilter and anchors, for matching against URLs later. + * - RequestDataForUrlFilter class precomputes the URL / domain anchors to + * support matching more efficiently. + * - CompiledUrlFilter's matchesRequest(request) checks whether the request is + * actually matched, using the precomputed information. + * + * The class was designed to minimize the number of string allocations during + * request evaluation, because the matchesRequest method may be called very + * often for every network request. + */ +class CompiledUrlFilter { + #isUrlFilterCaseSensitive; + #urlFilterParts; // = parts of urlFilter, minus anchors, split at "*". + // isAnchorLeft and isAnchorDomain are mutually exclusive. + #isAnchorLeft = false; + #isAnchorDomain = false; + #isAnchorRight = false; + #isTrailingSeparator = false; // Whether urlFilter ends with "^". + + /** + * @param {string} urlFilter - non-empty urlFilter + * @param {boolean} [isUrlFilterCaseSensitive] + */ + constructor(urlFilter, isUrlFilterCaseSensitive) { + this.#isUrlFilterCaseSensitive = isUrlFilterCaseSensitive; + this.#initializeUrlFilter(urlFilter, isUrlFilterCaseSensitive); + } + + #initializeUrlFilter(urlFilter, isUrlFilterCaseSensitive) { + let start = 0; + let end = urlFilter.length; + + // First, trim the anchors off urlFilter. + if (urlFilter[0] === "|") { + if (urlFilter[1] === "|") { + start = 2; + this.#isAnchorDomain = true; + // ^ will not revert to false below, because "||*" is already rejected + // by RuleValidator's #checkCondUrlFilterAndRegexFilter method. + } else { + start = 1; + this.#isAnchorLeft = true; // may revert to false below. + } + } + if (end > start && urlFilter[end - 1] === "|") { + --end; + this.#isAnchorRight = true; // may revert to false below. + } + + // Skip unnecessary wildcards, and adjust meaningless anchors accordingly: + // "|*" and "*|" are not effective anchors, they could have been omitted. + while (start < end && urlFilter[start] === "*") { + ++start; + this.#isAnchorLeft = false; + } + while (end > start && urlFilter[end - 1] === "*") { + --end; + this.#isAnchorRight = false; + } + + // Special-case the last "^", so that the matching algorithm can rely on + // the simple assumption that a "^" in the filter matches exactly one char: + // The "^" at the end of the pattern is specified to match either one char + // as usual, or as an anchor for the end of the URL (i.e. zero characters). + this.#isTrailingSeparator = urlFilter[end - 1] === "^"; + + let urlFilterWithoutAnchors = urlFilter.slice(start, end); + if (!isUrlFilterCaseSensitive) { + urlFilterWithoutAnchors = urlFilterWithoutAnchors.toLowerCase(); + } + this.#urlFilterParts = urlFilterWithoutAnchors.split("*"); + } + + /** + * Tests whether |request| matches the urlFilter. + * + * @param {RequestDataForUrlFilter} requestDataForUrlFilter + * @returns {boolean} Whether the condition matches the URL. + */ + matchesRequest(requestDataForUrlFilter) { + const url = requestDataForUrlFilter.getUrl(this.#isUrlFilterCaseSensitive); + const domainAnchors = requestDataForUrlFilter.domainAnchors; + + const urlFilterParts = this.#urlFilterParts; + + const REAL_END_OF_URL = url.length - 1; // minus trailing "^" + + // atUrlIndex is the position after the most recently matched part. + // If a match is not found, it is -1 and we should return false. + let atUrlIndex = 0; + + // The head always exists, potentially even an empty string. + const head = urlFilterParts[0]; + if (this.#isAnchorLeft) { + if (!this.#startsWithPart(head, url, 0)) { + return false; + } + atUrlIndex = head.length; + } else if (this.#isAnchorDomain) { + atUrlIndex = this.#indexAfterDomainPart(head, url, domainAnchors); + } else { + atUrlIndex = this.#indexAfterPart(head, url, 0); + } + + let previouslyAtUrlIndex = 0; + for (let i = 1; i < urlFilterParts.length && atUrlIndex !== -1; ++i) { + previouslyAtUrlIndex = atUrlIndex; + atUrlIndex = this.#indexAfterPart(urlFilterParts[i], url, atUrlIndex); + } + if (atUrlIndex === -1) { + return false; + } + if (atUrlIndex === url.length) { + // We always append a "^" to the URL, so if the match is at the end of the + // URL (REAL_END_OF_URL), only accept if the pattern ended with a "^". + return this.#isTrailingSeparator; + } + if (!this.#isAnchorRight || atUrlIndex === REAL_END_OF_URL) { + // Either not interested in the end, or already at the end of the URL. + return true; + } + + // #isAnchorRight is true but we are not at the end of the URL. + // Backtrack once, to retry the last pattern (tail) with the end of the URL. + + const tail = urlFilterParts[urlFilterParts.length - 1]; + // The expected offset where the tail should be located. + const expectedTailIndex = REAL_END_OF_URL - tail.length; + // If #isTrailingSeparator is true, then accept the URL's trailing "^". + const expectedTailIndexPlus1 = expectedTailIndex + 1; + if (urlFilterParts.length === 1) { + if (this.#isAnchorLeft) { + // If matched, we would have returned at the REAL_END_OF_URL checks. + return false; + } + if (this.#isAnchorDomain) { + // The tail must be exactly at one of the domain anchors. + return ( + (domainAnchors.includes(expectedTailIndex) && + this.#startsWithPart(tail, url, expectedTailIndex)) || + (this.#isTrailingSeparator && + domainAnchors.includes(expectedTailIndexPlus1) && + this.#startsWithPart(tail, url, expectedTailIndexPlus1)) + ); + } + // head has no left/domain anchor, fall through. + } + // The tail is not left/domain anchored, accept it as long as it did not + // overlap with an already-matched part of the URL. + return ( + (expectedTailIndex > previouslyAtUrlIndex && + this.#startsWithPart(tail, url, expectedTailIndex)) || + (this.#isTrailingSeparator && + expectedTailIndexPlus1 > previouslyAtUrlIndex && + this.#startsWithPart(tail, url, expectedTailIndexPlus1)) + ); + } + + // Whether a character should match "^" in an urlFilter. + // The "match end of URL" meaning of "^" is covered by #isTrailingSeparator. + static #regexIsSep = /[^A-Za-z0-9_\-.%]/; + + #matchPartAt(part, url, urlIndex, sepStart) { + if (sepStart === -1) { + // Fast path. + return url.startsWith(part, urlIndex); + } + if (urlIndex + part.length > url.length) { + return false; + } + for (let i = 0; i < part.length; ++i) { + let partChar = part[i]; + let urlChar = url[urlIndex + i]; + if ( + partChar !== urlChar && + (partChar !== "^" || !CompiledUrlFilter.#regexIsSep.test(urlChar)) + ) { + return false; + } + } + return true; + } + + #startsWithPart(part, url, urlIndex) { + const sepStart = part.indexOf("^"); + return this.#matchPartAt(part, url, urlIndex, sepStart); + } + + #indexAfterPart(part, url, urlIndex) { + let sepStart = part.indexOf("^"); + if (sepStart === -1) { + // Fast path. + let i = url.indexOf(part, urlIndex); + return i === -1 ? i : i + part.length; + } + let maxUrlIndex = url.length - part.length; + for (let i = urlIndex; i <= maxUrlIndex; ++i) { + if (this.#matchPartAt(part, url, i, sepStart)) { + return i + part.length; + } + } + return -1; + } + + #indexAfterDomainPart(part, url, domainAnchors) { + const sepStart = part.indexOf("^"); + for (let offset of domainAnchors) { + if (this.#matchPartAt(part, url, offset, sepStart)) { + return offset + part.length; + } + } + return -1; + } +} + +// See CompiledUrlFilter for documentation of RequestDataForUrlFilter. +class RequestDataForUrlFilter { + /** + * @param {string} requestURIspec - The URL to match against. + */ + constructor(requestURIspec) { + // "^" is appended, see CompiledUrlFilter's #initializeUrlFilter. + this.urlAnyCase = requestURIspec + "^"; + this.urlLowerCase = this.urlAnyCase.toLowerCase(); + // For "||..." (Domain name anchor): where (sub)domains start in the URL. + this.domainAnchors = this.#getDomainAnchors(this.urlAnyCase); + } + + getUrl(isUrlFilterCaseSensitive) { + return isUrlFilterCaseSensitive ? this.urlAnyCase : this.urlLowerCase; + } + + #getDomainAnchors(url) { + let hostStart = url.indexOf("://") + 3; + let hostEnd = url.indexOf("/", hostStart); + let userpassEnd = url.lastIndexOf("@", hostEnd) + 1; + if (userpassEnd) { + hostStart = userpassEnd; + } + let host = url.slice(hostStart, hostEnd); + let domainAnchors = [hostStart]; + let offset = 0; + // Find all offsets after ".". If not found, -1 + 1 = 0, and the loop ends. + while ((offset = host.indexOf(".", offset) + 1)) { + domainAnchors.push(hostStart + offset); + } + return domainAnchors; + } +} + +function compileRegexFilter(regexFilter, isUrlFilterCaseSensitive) { + // TODO bug 1821033: Restrict supported regex to avoid perf issues. For + // discussion on the desired syntax, see + // https://github.com/w3c/webextensions/issues/344 + return new RegExp(regexFilter, isUrlFilterCaseSensitive ? "" : "i"); +} + +class ModifyHeadersBase { + // Map - The first MatchedRule that modified the header. + // After modifying a header, it cannot be modified further, with the exception + // of the "append" operation, provided that they are from the same extension. + #alreadyModifiedMap = new Map(); + // Set - The list of headers allowed to be modified with "append", + // despite having been modified. Allowed for "set"/"append", not for "remove". + #appendStillAllowed = new Set(); + + /** + * @param {ChannelWrapper} channel + */ + constructor(channel) { + this.channel = channel; + } + + /** + * @param {MatchedRule} matchedRule + * @returns {object[]} + */ + headerActionsFor(matchedRule) { + throw new Error("Not implemented."); + } + + /** + * @param {MatchedRule} matchedrule + * @param {string} name + * @param {string} value + * @param {boolean} merge + */ + setHeaderImpl(matchedrule, name, value, merge) { + throw new Error("Not implemented."); + } + + /** @param {MatchedRule[]} matchedRules */ + applyModifyHeaders(matchedRules) { + for (const matchedRule of matchedRules) { + for (const headerAction of this.headerActionsFor(matchedRule)) { + const { header: name, operation, value } = headerAction; + if (!this.#isOperationAllowed(name, operation, matchedRule)) { + continue; + } + let ok; + switch (operation) { + case "set": + ok = this.setHeader(matchedRule, name, value, /* merge */ false); + if (ok) { + this.#appendStillAllowed.add(name); + } + break; + case "append": + ok = this.setHeader(matchedRule, name, value, /* merge */ true); + if (ok) { + this.#appendStillAllowed.add(name); + } + break; + case "remove": + ok = this.setHeader(matchedRule, name, "", /* merge */ false); + // Note: removal is final, so we don't add to #appendStillAllowed. + break; + } + if (ok) { + this.#alreadyModifiedMap.set(name, matchedRule); + } + } + } + } + + #isOperationAllowed(name, operation, matchedRule) { + const modifiedBy = this.#alreadyModifiedMap.get(name); + if (!modifiedBy) { + return true; + } + if ( + operation === "append" && + this.#appendStillAllowed.has(name) && + matchedRule.ruleManager === modifiedBy.ruleManager + ) { + return true; + } + // TODO bug 1803369: dev experience improvement: consider logging when + // a header modification was rejected. + return false; + } + + setHeader(matchedRule, name, value, merge) { + try { + this.setHeaderImpl(matchedRule, name, value, merge); + return true; + } catch (e) { + const extension = matchedRule.ruleManager.extension; + extension.logger.error( + `Failed to apply modifyHeaders action to header "${name}" (DNR rule id ${matchedRule.rule.id} from ruleset "${matchedRule.ruleset.id}"): ${e}` + ); + } + return false; + } + + // kName should already be in lower case. + isHeaderNameEqual(name, kName) { + return name.length === kName.length && name.toLowerCase() === kName; + } +} + +class ModifyRequestHeaders extends ModifyHeadersBase { + static maybeApplyModifyHeaders(channel, matchedRules) { + matchedRules = matchedRules.filter(mr => { + const action = mr.rule.action; + return action.type === "modifyHeaders" && action.requestHeaders?.length; + }); + if (matchedRules.length) { + new ModifyRequestHeaders(channel).applyModifyHeaders(matchedRules); + } + } + + /** @param {MatchedRule} matchedRule */ + headerActionsFor(matchedRule) { + return matchedRule.rule.action.requestHeaders; + } + + setHeaderImpl(matchedRule, name, value, merge) { + if (this.isHeaderNameEqual(name, "host")) { + this.#checkHostHeader(matchedRule, value); + } + if (merge && value && this.isHeaderNameEqual(name, "cookie")) { + // By default, headers are merged with ",". But Cookie should use "; ". + // HTTP/1.1 allowed only one Cookie header, but HTTP/2.0 allows multiple, + // but recommends concatenation on one line. Relevant RFCs: + // - https://www.rfc-editor.org/rfc/rfc6265#section-5.4 + // - https://www.rfc-editor.org/rfc/rfc7540#section-8.1.2.5 + // Consistent with Firefox internals, we ensure that there is at most one + // Cookie header, by overwriting the previous one, if any. + let existingCookie = this.channel.getRequestHeader("cookie"); + if (existingCookie) { + value = existingCookie + "; " + value; + merge = false; + } + } + this.channel.setRequestHeader(name, value, merge); + } + + #checkHostHeader(matchedRule, value) { + let uri = Services.io.newURI(`https://${value}/`); + let { policy } = matchedRule.ruleManager.extension; + + if (!policy.allowedOrigins.matches(uri)) { + throw new Error( + `Unable to set host header, url missing from permissions.` + ); + } + + if (WebExtensionPolicy.isRestrictedURI(uri)) { + throw new Error(`Unable to set host header to restricted url.`); + } + } +} + +class ModifyResponseHeaders extends ModifyHeadersBase { + static maybeApplyModifyHeaders(channel, matchedRules) { + matchedRules = matchedRules.filter(mr => { + const action = mr.rule.action; + return action.type === "modifyHeaders" && action.responseHeaders?.length; + }); + if (matchedRules.length) { + new ModifyResponseHeaders(channel).applyModifyHeaders(matchedRules); + } + } + + headerActionsFor(matchedRule) { + return matchedRule.rule.action.responseHeaders; + } + + setHeaderImpl(matchedRule, name, value, merge) { + this.channel.setResponseHeader(name, value, merge); + } +} + +class RuleValidator { + constructor(alreadyValidatedRules, { isSessionRuleset = false } = {}) { + this.rulesMap = new Map(alreadyValidatedRules.map(r => [r.id, r])); + this.failures = []; + this.isSessionRuleset = isSessionRuleset; + } + + /** + * Static method used to deserialize Rule class instances from a plain + * js object rule as serialized implicitly by aomStartup.encodeBlob + * when we store the rules into the startup cache file. + * + * @param {object} rule + * @returns {Rule} + */ + static deserializeRule(rule) { + const newRule = new Rule(rule); + if (newRule.condition.regexFilter) { + newRule.condition.setCompiledRegexFilter( + compileRegexFilter( + newRule.condition.regexFilter, + newRule.condition.isUrlFilterCaseSensitive + ) + ); + } + return newRule; + } + + removeRuleIds(ruleIds) { + for (const ruleId of ruleIds) { + this.rulesMap.delete(ruleId); + } + } + + /** + * @param {object[]} rules - A list of objects that adhere to the Rule type + * from declarative_net_request.json. + */ + addRules(rules) { + for (const rule of rules) { + if (this.rulesMap.has(rule.id)) { + this.#collectInvalidRule(rule, `Duplicate rule ID: ${rule.id}`); + continue; + } + // declarative_net_request.json defines basic types, such as the expected + // object properties and (primitive) type. Trivial constraints such as + // minimum array lengths are also expressed in the schema. + // Anything more complex is validated here. In particular, constraints + // involving multiple properties (e.g. mutual exclusiveness). + // + // The following conditions have already been validated by the schema: + // - isUrlFilterCaseSensitive (boolean) + // - domainType (enum string) + // - initiatorDomains & excludedInitiatorDomains & requestDomains & + // excludedRequestDomains (array of string in canonicalDomain format) + if ( + !this.#checkCondResourceTypes(rule) || + !this.#checkCondRequestMethods(rule) || + !this.#checkCondTabIds(rule) || + !this.#checkCondUrlFilterAndRegexFilter(rule) || + !this.#checkAction(rule) + ) { + continue; + } + + const newRule = new Rule(rule); + // #lastCompiledRegexFilter is set if regexFilter is set, and null + // otherwise by the above call to #checkCondUrlFilterAndRegexFilter(). + if (this.#lastCompiledRegexFilter) { + newRule.condition.setCompiledRegexFilter(this.#lastCompiledRegexFilter); + } + + this.rulesMap.set(rule.id, newRule); + } + } + + // #checkCondUrlFilterAndRegexFilter() compiles the regexFilter to check its + // validity. To avoid having to compile it again when the Rule (RuleCondition) + // is constructed, we temporarily cache the result. + #lastCompiledRegexFilter; + + // Checks: resourceTypes & excludedResourceTypes + #checkCondResourceTypes(rule) { + const { resourceTypes, excludedResourceTypes } = rule.condition; + if (this.#hasOverlap(resourceTypes, excludedResourceTypes)) { + this.#collectInvalidRule( + rule, + "resourceTypes and excludedResourceTypes should not overlap" + ); + return false; + } + if (rule.action.type === "allowAllRequests") { + if (!resourceTypes) { + this.#collectInvalidRule( + rule, + "An allowAllRequests rule must have a non-empty resourceTypes array" + ); + return false; + } + if (resourceTypes.some(r => r !== "main_frame" && r !== "sub_frame")) { + this.#collectInvalidRule( + rule, + "An allowAllRequests rule may only include main_frame/sub_frame in resourceTypes" + ); + return false; + } + } + return true; + } + + // Checks: requestMethods & excludedRequestMethods + #checkCondRequestMethods(rule) { + const { requestMethods, excludedRequestMethods } = rule.condition; + if (this.#hasOverlap(requestMethods, excludedRequestMethods)) { + this.#collectInvalidRule( + rule, + "requestMethods and excludedRequestMethods should not overlap" + ); + return false; + } + const isInvalidRequestMethod = method => method.toLowerCase() !== method; + if ( + requestMethods?.some(isInvalidRequestMethod) || + excludedRequestMethods?.some(isInvalidRequestMethod) + ) { + this.#collectInvalidRule(rule, "request methods must be in lower case"); + return false; + } + return true; + } + + // Checks: tabIds & excludedTabIds + #checkCondTabIds(rule) { + const { tabIds, excludedTabIds } = rule.condition; + + if ((tabIds || excludedTabIds) && !this.isSessionRuleset) { + this.#collectInvalidRule( + rule, + "tabIds and excludedTabIds can only be specified in session rules" + ); + return false; + } + + if (this.#hasOverlap(tabIds, excludedTabIds)) { + this.#collectInvalidRule( + rule, + "tabIds and excludedTabIds should not overlap" + ); + return false; + } + return true; + } + + static #regexNonASCII = /[^\x00-\x7F]/; // eslint-disable-line no-control-regex + static #regexDigitOrBackslash = /^[0-9\\]$/; + + // Checks: urlFilter & regexFilter + #checkCondUrlFilterAndRegexFilter(rule) { + const { urlFilter, regexFilter } = rule.condition; + + this.#lastCompiledRegexFilter = null; + + const checkEmptyOrNonASCII = (str, prop) => { + if (!str) { + this.#collectInvalidRule(rule, `${prop} should not be an empty string`); + return false; + } + // Non-ASCII in URLs are always encoded in % (or punycode in domains). + if (RuleValidator.#regexNonASCII.test(str)) { + this.#collectInvalidRule( + rule, + `${prop} should not contain non-ASCII characters` + ); + return false; + } + return true; + }; + if (urlFilter != null) { + if (regexFilter != null) { + this.#collectInvalidRule( + rule, + "urlFilter and regexFilter are mutually exclusive" + ); + return false; + } + if (!checkEmptyOrNonASCII(urlFilter, "urlFilter")) { + // #collectInvalidRule already called by checkEmptyOrNonASCII. + return false; + } + if (urlFilter.startsWith("||*")) { + // Rejected because Chrome does too. '||*' is equivalent to '*'. + this.#collectInvalidRule(rule, "urlFilter should not start with '||*'"); + return false; + } + } else if (regexFilter != null) { + if (!checkEmptyOrNonASCII(regexFilter, "regexFilter")) { + // #collectInvalidRule already called by checkEmptyOrNonASCII. + return false; + } + try { + this.#lastCompiledRegexFilter = compileRegexFilter( + regexFilter, + rule.condition.isUrlFilterCaseSensitive + ); + } catch (e) { + this.#collectInvalidRule( + rule, + "regexFilter is not a valid regular expression" + ); + return false; + } + } + return true; + } + + #checkAction(rule) { + switch (rule.action.type) { + case "allow": + case "allowAllRequests": + case "block": + case "upgradeScheme": + // These actions have no extra properties. + break; + case "redirect": + return this.#checkActionRedirect(rule); + case "modifyHeaders": + return this.#checkActionModifyHeaders(rule); + default: + // Other values are not possible because declarative_net_request.json + // only accepts the above action types. + throw new Error(`Unexpected action type: ${rule.action.type}`); + } + return true; + } + + #checkActionRedirect(rule) { + const { url, extensionPath, transform, regexSubstitution } = + rule.action.redirect ?? {}; + const hasExtensionPath = extensionPath != null; + const hasRegexSubstitution = regexSubstitution != null; + const redirectKeyCount = // @ts-ignore trivial/noisy + !!url + !!hasExtensionPath + !!transform + !!hasRegexSubstitution; + if (redirectKeyCount !== 1) { + if (redirectKeyCount === 0) { + this.#collectInvalidRule( + rule, + "A redirect rule must have a non-empty action.redirect object" + ); + return false; + } + // Side note: Chrome silently ignores excess keys, and skips validation + // for ignored keys, in this order: + // - url > extensionPath > transform > regexSubstitution + this.#collectInvalidRule( + rule, + "redirect.url, redirect.extensionPath, redirect.transform and redirect.regexSubstitution are mutually exclusive" + ); + return false; + } + + if (hasExtensionPath && !extensionPath.startsWith("/")) { + this.#collectInvalidRule( + rule, + "redirect.extensionPath should start with a '/'" + ); + return false; + } + + // If specified, the "url" property is described as "format": "url" in the + // JSON schema, which ensures that the URL is a canonical form, and that + // the extension is allowed to trigger a navigation to the URL. + // E.g. javascript: and privileged about:-URLs cannot be navigated to, but + // http(s) URLs can (regardless of extension permissions). + // data:-URLs are currently blocked due to bug 1622986. + + if (transform) { + if (transform.query != null && transform.queryTransform) { + this.#collectInvalidRule( + rule, + "redirect.transform.query and redirect.transform.queryTransform are mutually exclusive" + ); + return false; + } + // Most of the validation is done by nsIURIMutator via applyURLTransform. + // nsIURIMutator is not very strict, so we perform some extra checks here + // to reject values that are not technically valid URLs. + + if (transform.port && /\D/.test(transform.port)) { + // nsIURIMutator's setPort takes an int, so any string will implicitly + // be converted to a number. This part verifies that the input only + // consists of digits. setPort will ensure that it is at most 65535. + this.#collectInvalidRule( + rule, + "redirect.transform.port should be empty or an integer" + ); + return false; + } + + // Note: we don't verify whether transform.query starts with '/', because + // Chrome does not require it, and nsIURIMutator prepends it if missing. + + if (transform.query && !transform.query.startsWith("?")) { + this.#collectInvalidRule( + rule, + "redirect.transform.query should be empty or start with a '?'" + ); + return false; + } + if (transform.fragment && !transform.fragment.startsWith("#")) { + this.#collectInvalidRule( + rule, + "redirect.transform.fragment should be empty or start with a '#'" + ); + return false; + } + try { + const dummyURI = Services.io.newURI("http://dummy"); + // applyURLTransform uses nsIURIMutator to transform a URI, and throws + // if |transform| is invalid, e.g. invalid host, port, etc. + applyURLTransform(dummyURI, transform); + } catch (e) { + this.#collectInvalidRule( + rule, + "redirect.transform does not describe a valid URL transformation" + ); + return false; + } + } + + if (hasRegexSubstitution) { + if (!rule.condition.regexFilter) { + this.#collectInvalidRule( + rule, + "redirect.regexSubstitution requires the regexFilter condition to be specified" + ); + return false; + } + let i = 0; + // i will be index after \. Loop breaks if not found (-1+1=0 = false). + while ((i = regexSubstitution.indexOf("\\", i) + 1)) { + let c = regexSubstitution[i++]; // may be undefined if \ is at end. + if (c === undefined || !RuleValidator.#regexDigitOrBackslash.test(c)) { + this.#collectInvalidRule( + rule, + "redirect.regexSubstitution only allows digit or \\ after \\." + ); + return false; + } + } + } + + return true; + } + + #checkActionModifyHeaders(rule) { + const { requestHeaders, responseHeaders } = rule.action; + if (!requestHeaders && !responseHeaders) { + this.#collectInvalidRule( + rule, + "A modifyHeaders rule must have a non-empty requestHeaders or modifyHeaders list" + ); + return false; + } + + const isValidModifyHeadersOp = ({ header, operation, value }) => { + if (!header) { + this.#collectInvalidRule(rule, "header must be non-empty"); + return false; + } + if (!value && (operation === "append" || operation === "set")) { + this.#collectInvalidRule( + rule, + "value is required for operations append/set" + ); + return false; + } + if (value && operation === "remove") { + this.#collectInvalidRule( + rule, + "value must not be provided for operation remove" + ); + return false; + } + return true; + }; + if ( + (requestHeaders && !requestHeaders.every(isValidModifyHeadersOp)) || + (responseHeaders && !responseHeaders.every(isValidModifyHeadersOp)) + ) { + // #collectInvalidRule already called by isValidModifyHeadersOp. + return false; + } + return true; + } + + // Conditions with a filter and an exclude-filter should reject overlapping + // lists, because they can never simultaneously be true. + #hasOverlap(arrayA, arrayB) { + return arrayA && arrayB && arrayA.some(v => arrayB.includes(v)); + } + + #collectInvalidRule(rule, message) { + this.failures.push({ rule, message }); + } + + getValidatedRules() { + return Array.from(this.rulesMap.values()); + } + + getFailures() { + return this.failures; + } +} + +export class RuleQuotaCounter { + constructor(isStaticRulesets) { + this.isStaticRulesets = isStaticRulesets; + this.ruleLimitName = isStaticRulesets + ? "GUARANTEED_MINIMUM_STATIC_RULES" + : "MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES"; + this.ruleLimitRemaining = lazy.ExtensionDNRLimits[this.ruleLimitName]; + this.regexRemaining = lazy.ExtensionDNRLimits.MAX_NUMBER_OF_REGEX_RULES; + } + + tryAddRules(rulesetId, rules) { + if (rules.length > this.ruleLimitRemaining) { + this.#throwQuotaError(rulesetId, "rules", this.ruleLimitName); + } + let regexCount = 0; + for (let rule of rules) { + if (rule.condition.regexFilter && ++regexCount > this.regexRemaining) { + this.#throwQuotaError( + rulesetId, + "regexFilter rules", + "MAX_NUMBER_OF_REGEX_RULES" + ); + } + } + + // Update counters only when there are no quota errors. + this.ruleLimitRemaining -= rules.length; + this.regexRemaining -= regexCount; + } + + #throwQuotaError(rulesetId, what, limitName) { + if (this.isStaticRulesets) { + throw new ExtensionError( + `Number of ${what} across all enabled static rulesets exceeds ${limitName} if ruleset "${rulesetId}" were to be enabled.` + ); + } + throw new ExtensionError( + `Number of ${what} in ruleset "${rulesetId}" exceeds ${limitName}.` + ); + } +} + +/** + * Compares two rules to determine the relative order of precedence. + * Rules are only comparable if they are from the same extension! + * + * @param {Rule} ruleA + * @param {Rule} ruleB + * @param {Ruleset} rulesetA - the ruleset ruleA is part of. + * @param {Ruleset} rulesetB - the ruleset ruleB is part of. + * @returns {integer} + * 0 if equal. + * <0 if ruleA comes before ruleB. + * >0 if ruleA comes after ruleB. + */ +function compareRule(ruleA, ruleB, rulesetA, rulesetB) { + // Comparators: 0 if equal, >0 if a after b, <0 if a before b. + function cmpHighestNumber(a, b) { + return a === b ? 0 : b - a; + } + function cmpLowestNumber(a, b) { + return a === b ? 0 : a - b; + } + return ( + // All compared operands are non-negative integers. + cmpHighestNumber(ruleA.priority, ruleB.priority) || + cmpLowestNumber(ruleA.actionPrecedence(), ruleB.actionPrecedence()) || + // As noted in the big comment at the top of the file, the following two + // comparisons only exist in order to have a stable ordering of rules. The + // specific comparison is somewhat arbitrary and matches Chrome's behavior. + // For context, see https://github.com/w3c/webextensions/issues/280 + cmpLowestNumber(rulesetA.rulesetPrecedence, rulesetB.rulesetPrecedence) || + cmpLowestNumber(ruleA.id, ruleB.id) + ); +} + +class MatchedRule { + /** + * @param {Rule} rule + * @param {Ruleset} ruleset + */ + constructor(rule, ruleset) { + this.rule = rule; + this.ruleset = ruleset; + } + + // The RuleManager that generated this MatchedRule. + get ruleManager() { + return this.ruleset.ruleManager; + } +} + +// tabId computation is currently not free, and depends on the initialization of +// ExtensionParent.apiManager.global (see WebRequest.getTabIdForChannelWrapper). +// Fortunately, DNR only supports tabIds in session rules, so by keeping track +// of session rules with tabIds/excludedTabIds conditions, we can find tabId +// exactly and only when necessary. +let gHasAnyTabIdConditions = false; + +class RequestDetails { + /** + * @param {object} options + * @param {nsIURI} options.requestURI - URL of the requested resource. + * @param {nsIURI} [options.initiatorURI] - URL of triggering principal, + * provided that it is a content principal. Otherwise null. + * @param {string} options.type - ResourceType (MozContentPolicyType). + * @param {string} [options.method] - HTTP method + * @param {integer} [options.tabId] + * @param {BrowsingContext} [options.browsingContext] - The BrowsingContext + * associated with the request. Typically the bc for which the subresource + * request is initiated, if any. For document requests, this is the parent + * (i.e. the parent frame for sub_frame, null for main_frame). + */ + constructor({ + requestURI, + initiatorURI, + type, + method, + tabId, + browsingContext, + }) { + this.requestURI = requestURI; + this.initiatorURI = initiatorURI; + this.type = type; + this.method = method; + this.tabId = tabId; + this.browsingContext = browsingContext; + + this.requestDomain = this.#domainFromURI(requestURI); + this.initiatorDomain = initiatorURI + ? this.#domainFromURI(initiatorURI) + : null; + + this.requestURIspec = requestURI.spec; + this.requestDataForUrlFilter = new RequestDataForUrlFilter( + this.requestURIspec + ); + } + + static fromChannelWrapper(channel) { + let tabId = -1; + if (gHasAnyTabIdConditions) { + tabId = lazy.WebRequest.getTabIdForChannelWrapper(channel); + } + return new RequestDetails({ + requestURI: channel.finalURI, + // Note: originURI may be null, if missing or null principal, as desired. + initiatorURI: channel.originURI, + type: channel.type, + method: channel.method.toLowerCase(), + tabId, + browsingContext: channel.loadInfo.browsingContext, + }); + } + + #ancestorRequestDetails; + get ancestorRequestDetails() { + if (this.#ancestorRequestDetails) { + return this.#ancestorRequestDetails; + } + this.#ancestorRequestDetails = []; + if (!this.browsingContext?.ancestorsAreCurrent) { + // this.browsingContext is set for real requests (via fromChannelWrapper). + // It may be void for testMatchOutcome and for the ancestor requests + // simulated below. + // + // ancestorsAreCurrent being false is unexpected, but could theoretically + // happen if the request is triggered from an unloaded (sub)frame. In that + // case we don't want to use potentially incorrect ancestor information. + // + // In any case, nothing left to do. + return this.#ancestorRequestDetails; + } + // Reconstruct the frame hierarchy of the request's document, in order to + // retroactively recompute the relevant matches of allowAllRequests rules. + // + // The allowAllRequests rule is supposedly applying to all subresource + // requests. For non-document requests, this is usually the document if any. + // In case of document requests, there is some ambiguity: + // - Usually, the initiator is the parent document that created the frame. + // - Sometimes, the initiator is a different frame or even another window. + // + // In RequestDetails.fromChannelWrapper, the actual initiator is used and + // reflected in initiatorURI, but here we use the document's parent. This + // is done because the chain of initiators is unstable (e.g. an opener can + // navigate/unload), whereas frame ancestor chain is constant as long as + // the leaf BrowsingContext is current. Moreover, allowAllRequests was + // originally designed to operate on frame hierarchies (crbug.com/1038831). + // + // This implementation of "initiator" for "allowAllRequests" is consistent + // with Chrome and Safari. + for (let bc = this.browsingContext; bc; bc = bc.parent) { + // Note: requestURI may differ from the document's initial requestURI, + // e.g. due to same-document navigations. + const requestURI = bc.currentURI; + if (!requestURI.schemeIs("https") && !requestURI.schemeIs("http")) { + // DNR is currently only hooked up to http(s) requests. Ignore other + // URLs, e.g. about:, blob:, moz-extension:, data:, etc. + continue; + } + const isTop = !bc.parent; + const parentPrin = bc.parentWindowContext?.documentPrincipal; + const requestDetails = new RequestDetails({ + requestURI, + // Note: initiatorURI differs from RequestDetails.fromChannelWrapper; + // See the above comment for more info. + initiatorURI: parentPrin?.isContentPrincipal ? parentPrin.URI : null, + type: isTop ? "main_frame" : "sub_frame", + method: bc.activeSessionHistoryEntry?.hasPostData ? "post" : "get", + tabId: this.tabId, + // In this loop we are already explicitly accounting for ancestors, so + // we intentionally omit browsingContext even though we have |bc|. If + // we were to set `browsingContext: bc`, the output would be the same, + // but be derived from unnecessarily repeated request evaluations. + browsingContext: null, + }); + this.#ancestorRequestDetails.unshift(requestDetails); + } + return this.#ancestorRequestDetails; + } + + canExtensionModify(extension) { + const policy = extension.policy; + if (!policy.canAccessURI(this.requestURI)) { + return false; + } + if ( + this.initiatorURI && + this.type !== "main_frame" && + this.type !== "sub_frame" && + !policy.canAccessURI(this.initiatorURI) + ) { + // Host permissions for the initiator is required except for navigation + // requests: https://bugzilla.mozilla.org/show_bug.cgi?id=1825824#c2 + return false; + } + return true; + } + + #domainFromURI(uri) { + try { + let hostname = uri.host; + // nsIURI omits brackets from IPv6 addresses. But the canonical form of an + // IPv6 address is with brackets, so add them. + return hostname.includes(":") ? `[${hostname}]` : hostname; + } catch (e) { + // uri.host throws for some schemes (e.g. about:). In practice we won't + // encounter this for network (via NetworkIntegration.startDNREvaluation) + // because isRestrictedPrincipalURI filters the initiatorURI. Furthermore, + // because only http(s) requests are observed, requestURI is http(s). + // + // declarativeNetRequest.testMatchOutcome can pass arbitrary URIs and thus + // trigger the error in nsIURI::GetHost. + Cu.reportError(e); + return null; + } + } +} + +/** + * This RequestEvaluator class's logic is documented at the top of this file. + */ +class RequestEvaluator { + // private constructor, only used by RequestEvaluator.evaluateRequest. + constructor(request, ruleManager) { + this.req = request; + this.ruleManager = ruleManager; + this.canModify = request.canExtensionModify(ruleManager.extension); + + // These values are initialized by findMatchingRules(): + this.matchedRule = null; + this.matchedModifyHeadersRules = []; + this.didCheckAncestors = false; + this.findMatchingRules(); + } + + /** + * Finds the matched rules for the given request and extensions, + * according to the logic documented at the top of this file. + * + * @param {RequestDetails} request + * @param {RuleManager[]} ruleManagers + * The list of RuleManagers, ordered by importance of its extension. + * @returns {MatchedRule[]} + */ + static evaluateRequest(request, ruleManagers) { + // Helper to determine precedence of rules from different extensions. + function precedence(matchedRule) { + switch (matchedRule.rule.action.type) { + case "block": + return 1; + case "redirect": + case "upgradeScheme": + return 2; + case "allow": + case "allowAllRequests": + return 3; + // case "modifyHeaders": not comparable after the first pass. + default: + throw new Error(`Unexpected action: ${matchedRule.rule.action.type}`); + } + } + + let requestEvaluators = []; + let finalMatch; + for (let ruleManager of ruleManagers) { + // Evaluate request with findMatchingRules(): + const requestEvaluator = new RequestEvaluator(request, ruleManager); + // RequestEvaluator may be used after the loop when the request is + // accepted, to collect modifyHeaders/allow/allowAllRequests actions. + requestEvaluators.push(requestEvaluator); + let matchedRule = requestEvaluator.matchedRule; + if ( + matchedRule && + (!finalMatch || precedence(matchedRule) < precedence(finalMatch)) + ) { + // Before choosing the matched rule as finalMatch, check whether there + // is an allowAllRequests rule override among the ancestors. + requestEvaluator.findAncestorRuleOverride(); + matchedRule = requestEvaluator.matchedRule; + if (!finalMatch || precedence(matchedRule) < precedence(finalMatch)) { + finalMatch = matchedRule; + if (finalMatch.rule.action.type === "block") { + break; + } + } + } + } + if (finalMatch && !finalMatch.rule.isAllowOrAllowAllRequestsAction()) { + // Found block/redirect/upgradeScheme, request will be replaced. + return [finalMatch]; + } + // Request not canceled, collect all modifyHeaders actions: + let matchedRules = requestEvaluators + .map(re => re.getMatchingModifyHeadersRules()) + .flat(1); + + // ... and collect the allowAllRequests actions: + // Note: Only needed for testMatchOutcome, getMatchedRules (bug 1745765) and + // onRuleMatchedDebug (bug 1745773). Not for regular requests, since regular + // requests do not distinguish between no rule vs allow vs allowAllRequests. + let finalAllowAllRequestsMatches = []; + for (let requestEvaluator of requestEvaluators) { + // TODO bug 1745765 / bug 1745773: Uncomment findAncestorRuleOverride() + // when getMatchedRules() or onRuleMatchedDebug are implemented. + // requestEvaluator.findAncestorRuleOverride(); + let matchedRule = requestEvaluator.matchedRule; + if (matchedRule && matchedRule.rule.action.type === "allowAllRequests") { + // Even if a different extension wins the final match, an extension + // may want to record the "allowAllRequests" action for the future. + finalAllowAllRequestsMatches.push(matchedRule); + } + } + if (finalAllowAllRequestsMatches.length) { + matchedRules = finalAllowAllRequestsMatches.concat(matchedRules); + } + + // ... and collect the "allow" action. At this point, finalMatch could also + // be a modifyHeaders or allowAllRequests action, but these would already + // have been added to the matchedRules result before. + if (finalMatch && finalMatch.rule.action.type === "allow") { + matchedRules.unshift(finalMatch); + } + return matchedRules; + } + + /** + * Finds the matching rules, as documented in the comment before the class. + */ + findMatchingRules() { + if (!this.canModify && !this.ruleManager.hasBlockPermission) { + // If the extension cannot apply any action, don't bother. + return; + } + + this.#collectMatchInRuleset(this.ruleManager.sessionRules); + this.#collectMatchInRuleset(this.ruleManager.dynamicRules); + for (let ruleset of this.ruleManager.enabledStaticRules) { + this.#collectMatchInRuleset(ruleset); + } + + if (this.matchedRule && !this.#isRuleActionAllowed(this.matchedRule.rule)) { + this.matchedRule = null; + // Note: this.matchedModifyHeadersRules is [] because canModify access is + // checked before populating the list. + } + } + + /** + * Find an "allowAllRequests" rule among the ancestors that may override the + * current matchedRule and/or matchedModifyHeadersRules rules. + */ + findAncestorRuleOverride() { + if (this.didCheckAncestors) { + return; + } + this.didCheckAncestors = true; + + if (!this.ruleManager.hasRulesWithAllowAllRequests) { + // Optimization: Skip ancestorRequestDetails lookup and/or request + // evaluation if there are no allowAllRequests rules. + return; + } + + // Now we need to check whether any of the ancestor frames had a matching + // allowAllRequests rule. matchedRule and/or matchedModifyHeadersRules + // results may be ignored if their priority is lower or equal to the + // highest-priority allowAllRequests rule among the frame ancestors. + // + // In theory, every ancestor may potentially yield an allowAllRequests rule, + // and should therefore be checked unconditionally. But logically, if there + // are no existing matches, then any matching allowAllRequests rules will + // not have any effect on the request outcome. As an optimization, we + // therefore skip ancestor checks in this case. + if ( + (!this.matchedRule || + this.matchedRule.rule.isAllowOrAllowAllRequestsAction()) && + !this.matchedModifyHeadersRules.length + ) { + // Optimization: Do not look up ancestors if no rules were matched. + // + // TODO bug 1745773: onRuleMatchedDebug is supposed to report when a rule + // has been matched. To be pedantic, when there is an onRuleMatchedDebug + // listener, the parents need to be checked unconditionally, in order to + // report potential allowAllRequests matches among ancestors. + // TODO bug 1745765: the above may also apply to getMatchedRules(). + return; + } + + for (let request of this.req.ancestorRequestDetails) { + // TODO: Optimize by only evaluating allow/allowAllRequests rules, because + // the request being seen here implies that the request was not canceled, + // i.e. that there were no block/redirect/upgradeScheme rules in any of + // the ancestors (across all extensions!). + let requestEvaluator = new RequestEvaluator(request, this.ruleManager); + let ancestorMatchedRule = requestEvaluator.matchedRule; + if ( + ancestorMatchedRule && + ancestorMatchedRule.rule.action.type === "allowAllRequests" && + (!this.matchedRule || + compareRule( + this.matchedRule.rule, + ancestorMatchedRule.rule, + this.matchedRule.ruleset, + ancestorMatchedRule.ruleset + ) > 0) + ) { + // Found an allowAllRequests rule that takes precedence over whatever + // the current rule was. + this.matchedRule = ancestorMatchedRule; + } + } + } + + /** + * Retrieves the list of matched modifyHeaders rules that should apply. + * + * @returns {MatchedRule[]} + */ + getMatchingModifyHeadersRules() { + if (this.matchedModifyHeadersRules.length) { + // Find parent allowAllRequests rules, if any, to make sure that we can + // appropriately ignore same-or-lower-priority modifyHeaders rules. + this.findAncestorRuleOverride(); + } + // The minimum priority is 1. Defaulting to 0 = include all. + let priorityThreshold = 0; + if (this.matchedRule?.rule.isAllowOrAllowAllRequestsAction()) { + priorityThreshold = this.matchedRule.rule.priority; + } + // Note: the result cannot be non-empty if this.matchedRule is a non-allow + // action, because if that were to be the case, then the request would have + // been canceled, and therefore there would not be any header to modify. + // Even if another extension were to override the action, it could only be + // any other non-allow action, which would still cancel the request. + let matchedRules = this.matchedModifyHeadersRules.filter(matchedRule => { + return matchedRule.rule.priority > priorityThreshold; + }); + // Sort output for a deterministic order. + // NOTE: Sorting rules at registration (in RuleManagers) would avoid the + // need to sort here. Since the number of matched modifyHeaders rules are + // expected to be small, we don't bother optimizing. + matchedRules.sort((a, b) => { + return compareRule(a.rule, b.rule, a.ruleset, b.ruleset); + }); + return matchedRules; + } + + /** @param {Ruleset} ruleset */ + #collectMatchInRuleset(ruleset) { + for (let rule of ruleset.rules) { + if (!this.#matchesRuleCondition(rule.condition)) { + continue; + } + if (rule.action.type === "modifyHeaders") { + if (this.canModify) { + this.matchedModifyHeadersRules.push(new MatchedRule(rule, ruleset)); + } + continue; + } + if ( + this.matchedRule && + compareRule( + this.matchedRule.rule, + rule, + this.matchedRule.ruleset, + ruleset + ) <= 0 + ) { + continue; + } + this.matchedRule = new MatchedRule(rule, ruleset); + } + } + + /** + * @param {RuleCondition} cond + * @returns {boolean} Whether the condition matched. + */ + #matchesRuleCondition(cond) { + if (cond.resourceTypes) { + if (!cond.resourceTypes.includes(this.req.type)) { + return false; + } + } else if (cond.excludedResourceTypes) { + if (cond.excludedResourceTypes.includes(this.req.type)) { + return false; + } + } else if (this.req.type === "main_frame") { + // When resourceTypes/excludedResourceTypes are not specified, the + // documented behavior is to ignore main_frame requests. + return false; + } + + // Check this.req.requestURI: + if (cond.urlFilter) { + if (!cond.urlFilterMatches(this.req.requestDataForUrlFilter)) { + return false; + } + } else if (cond.regexFilter) { + if (!cond.getCompiledRegexFilter().test(this.req.requestURIspec)) { + return false; + } + } + if ( + cond.excludedRequestDomains && + this.#matchesDomains(cond.excludedRequestDomains, this.req.requestDomain) + ) { + return false; + } + if ( + cond.requestDomains && + !this.#matchesDomains(cond.requestDomains, this.req.requestDomain) + ) { + return false; + } + if ( + cond.excludedInitiatorDomains && + // Note: unable to only match null principals (bug 1798225). + this.req.initiatorDomain && + this.#matchesDomains( + cond.excludedInitiatorDomains, + this.req.initiatorDomain + ) + ) { + return false; + } + if ( + cond.initiatorDomains && + // Note: unable to only match null principals (bug 1798225). + (!this.req.initiatorDomain || + !this.#matchesDomains(cond.initiatorDomains, this.req.initiatorDomain)) + ) { + return false; + } + + // TODO bug 1797408: domainType + + if (cond.requestMethods) { + if (!cond.requestMethods.includes(this.req.method)) { + return false; + } + } else if (cond.excludedRequestMethods?.includes(this.req.method)) { + return false; + } + + if (cond.tabIds) { + if (!cond.tabIds.includes(this.req.tabId)) { + return false; + } + } else if (cond.excludedTabIds?.includes(this.req.tabId)) { + return false; + } + + return true; + } + + /** + * @param {string[]} domains - A list of canonicalized domain patterns. + * Canonical means punycode, no ports, and IPv6 without brackets, and not + * starting with a dot. May end with a dot if it is a FQDN. + * @param {string} host - The canonical representation of the host of a URL. + * @returns {boolean} Whether the given host is a (sub)domain of any of the + * given domains. + */ + #matchesDomains(domains, host) { + return domains.some(domain => { + return ( + host.endsWith(domain) && + // either host === domain + (host.length === domain.length || + // or host = "something." + domain (WITH a domain separator). + host.charAt(host.length - domain.length - 1) === ".") + ); + }); + } + + /** + * @param {Rule} rule - The final rule from the first pass. + * @returns {boolean} Whether the extension is allowed to execute the rule. + */ + #isRuleActionAllowed(rule) { + if (this.canModify) { + return true; + } + switch (rule.action.type) { + case "allow": + case "allowAllRequests": + case "block": + case "upgradeScheme": + return this.ruleManager.hasBlockPermission; + case "redirect": + return false; + // case "modifyHeaders" is never an action for this.matchedRule. + default: + throw new Error(`Unexpected action type: ${rule.action.type}`); + } + } +} + +/** + * Checks whether a request from a document with the given URI is allowed to + * be modified by an unprivileged extension (e.g. an extension without host + * permissions but the "declarativeNetRequest" permission). + * The output is comparable to WebExtensionPolicy::CanAccessURI for an extension + * with the `` permission, for consistency with the webRequest API. + * + * @param {nsIURI} [uri] The URI of a request's loadingPrincipal. May be void + * if missing (e.g. top-level requests) or not a content principal. + * @returns {boolean} Whether there is any extension that is allowed to see + * requests from a document with the given URI. Callers are expected to: + * - check system requests (and treat as true). + * - check WebExtensionPolicy.isRestrictedURI (and treat as true). + */ +function isRestrictedPrincipalURI(uri) { + if (!uri) { + // No URI, could be: + // - System principal (caller should have checked and disallowed access). + // - Expanded principal, typically content script in documents. If an + // extension content script managed to run there, that implies that an + // extension was able to access it. + // - Null principal (e.g. sandboxed document, about:blank, data:). + return false; + } + + // An unprivileged extension with maximal host permissions has allowedOrigins + // set to [``, `moz-extension://extensions-own-uuid-here`]. + // `` matches PermittedSchemes from MatchPattern.cpp: + // https://searchfox.org/mozilla-central/rev/55d5c4b9dffe5e59eb6b019c1a930ec9ada47e10/toolkit/components/extensions/MatchPattern.cpp#209-211 + // i.e. "http", "https", "ws", "wss", "file", "ftp", "data". + // - It is not possible to have a loadingPrincipal for: ws, wss, ftp. + // - data:-URIs always have an opaque origin, i.e. the principal is not a + // content principal, thus void here. + // - The remaining schemes from `` are: http, https, file, data, + // and checked below. + // + // Privileged addons can also access resource: and about:, but we do not need + // to support these now. + + // http(s) are common, and allowed, except for some restricted domains. The + // caller is expected to check WebExtensionPolicy.isRestrictedURI. + if (uri.schemeIs("http") || uri.schemeIs("https")) { + return false; // Very common. + } + + // moz-extension: is not restricted because an extension always has permission + // to its own moz-extension:-origin. The caller is expected to verify that an + // extension can only access its own URI. + if (uri.schemeIs("moz-extension")) { + return false; + } + + // Requests from local files are intentionally allowed (bug 1621935). + if (uri.schemeIs("file")) { + return false; + } + + // Anything else (e.g. resource:, about:newtab, etc.) is not allowed. + return true; +} + +const NetworkIntegration = { + maxEvaluatedRulesCount: 0, + + register() { + // We register via WebRequest.jsm to ensure predictable ordering of DNR and + // WebRequest behavior. + lazy.WebRequest.setDNRHandlingEnabled(true); + }, + unregister() { + lazy.WebRequest.setDNRHandlingEnabled(false); + }, + maybeUpdateTabIdChecker() { + gHasAnyTabIdConditions = gRuleManagers.some(rm => rm.hasRulesWithTabIds); + }, + + startDNREvaluation(channel) { + let ruleManagers = gRuleManagers; + // TODO bug 1827422: Merge isRestrictedPrincipalURI with canModify. + if (!channel.canModify || isRestrictedPrincipalURI(channel.documentURI)) { + // Ignore system requests or requests to restricted domains. + ruleManagers = []; + } + if (channel.loadInfo.originAttributes.privateBrowsingId > 0) { + ruleManagers = ruleManagers.filter( + rm => rm.extension.privateBrowsingAllowed + ); + } + if (ruleManagers.length && !lazy.gMatchRequestsFromOtherExtensions) { + const policy = channel.loadInfo.loadingPrincipal?.addonPolicy; + if (policy) { + ruleManagers = ruleManagers.filter( + rm => rm.extension.policy === policy + ); + } + } + let matchedRules; + if (ruleManagers.length) { + const evaluateRulesTimerId = + Glean.extensionsApisDnr.evaluateRulesTime.start(); + try { + const request = RequestDetails.fromChannelWrapper(channel); + matchedRules = RequestEvaluator.evaluateRequest(request, ruleManagers); + } finally { + if (evaluateRulesTimerId !== undefined) { + Glean.extensionsApisDnr.evaluateRulesTime.stopAndAccumulate( + evaluateRulesTimerId + ); + } + } + const evaluateRulesCount = ruleManagers.reduce( + (sum, ruleManager) => sum + ruleManager.getRulesCount(), + 0 + ); + if (evaluateRulesCount > this.maxEvaluatedRulesCount) { + Glean.extensionsApisDnr.evaluateRulesCountMax.set(evaluateRulesCount); + this.maxEvaluatedRulesCount = evaluateRulesCount; + } + } + // Cache for later. In case of redirects, _dnrMatchedRules may exist for + // the pre-redirect HTTP channel, and is overwritten here again. + channel._dnrMatchedRules = matchedRules; + }, + + /** + * Applies the actions of the DNR rules. + * + * @param {ChannelWrapper} channel + * @returns {boolean} Whether to ignore any responses from the webRequest API. + */ + onBeforeRequest(channel) { + let matchedRules = channel._dnrMatchedRules; + if (!matchedRules?.length) { + return false; + } + // If a matched rule closes the channel, it is the sole match. + const finalMatch = matchedRules[0]; + switch (finalMatch.rule.action.type) { + case "block": + this.applyBlock(channel, finalMatch); + return true; + case "redirect": + this.applyRedirect(channel, finalMatch); + return true; + case "upgradeScheme": + this.applyUpgradeScheme(channel, finalMatch); + return true; + } + // If there are multiple rules, then it may be a combination of allow, + // allowAllRequests and/or modifyHeaders. + + // "modifyHeaders" is handled by onBeforeSendHeaders/onHeadersReceived. + // "allow" and "allowAllRequests" require no further action now. + // "allowAllRequests" is applied to new requests in the future (if any) + // through RequestEvaluator's findAncestorRuleOverride(). + + return false; + }, + + onBeforeSendHeaders(channel) { + let matchedRules = channel._dnrMatchedRules; + if (!matchedRules?.length) { + return; + } + ModifyRequestHeaders.maybeApplyModifyHeaders(channel, matchedRules); + }, + + onHeadersReceived(channel) { + let matchedRules = channel._dnrMatchedRules; + if (!matchedRules?.length) { + return; + } + ModifyResponseHeaders.maybeApplyModifyHeaders(channel, matchedRules); + }, + + applyBlock(channel, matchedRule) { + // TODO bug 1802259: Consider a DNR-specific reason. + channel.cancel( + Cr.NS_ERROR_ABORT, + Ci.nsILoadInfo.BLOCKING_REASON_EXTENSION_WEBREQUEST + ); + const addonId = matchedRule.ruleManager.extension.id; + let properties = channel.channel.QueryInterface(Ci.nsIWritablePropertyBag); + properties.setProperty("cancelledByExtension", addonId); + }, + + applyUpgradeScheme(channel, matchedRule) { + // Request upgrade. No-op if already secure (i.e. https). + channel.upgradeToSecure(); + }, + + applyRedirect(channel, matchedRule) { + // Ambiguity resolution order of redirect dict keys, consistent with Chrome: + // - url > extensionPath > transform > regexSubstitution + const redirect = matchedRule.rule.action.redirect; + const extension = matchedRule.ruleManager.extension; + const preRedirectUri = channel.finalURI; + let redirectUri; + if (redirect.url) { + // redirect.url already validated by checkActionRedirect. + redirectUri = Services.io.newURI(redirect.url); + } else if (redirect.extensionPath) { + redirectUri = extension.baseURI + .mutate() + .setPathQueryRef(redirect.extensionPath) + .finalize(); + } else if (redirect.transform) { + redirectUri = applyURLTransform(preRedirectUri, redirect.transform); + } else if (redirect.regexSubstitution) { + // Note: may throw if regexSubstitution results in an invalid redirect. + // The error propagates up to handleRequest, which will just allow the + // request to continue. + redirectUri = applyRegexSubstitution(preRedirectUri, matchedRule); + } else { + // #checkActionRedirect ensures that the redirect action is non-empty. + } + + if (preRedirectUri.equals(redirectUri)) { + // URL did not change. Sometimes it is a bug in the extension, but there + // are also cases where the result is unavoidable. E.g. redirect.transform + // with queryTransform.removeParams that does not remove anything. + // TODO: consider logging to help with debugging. + return; + } + + channel.redirectTo(redirectUri); + + let properties = channel.channel.QueryInterface(Ci.nsIWritablePropertyBag); + properties.setProperty("redirectedByExtension", extension.id); + + let origin = channel.getRequestHeader("Origin"); + if (origin) { + channel.setResponseHeader("Access-Control-Allow-Origin", origin); + channel.setResponseHeader("Access-Control-Allow-Credentials", "true"); + channel.setResponseHeader("Access-Control-Max-Age", "0"); + } + }, +}; + +class RuleManager { + constructor(extension) { + this.extension = extension; + this.sessionRules = this.makeRuleset( + "_session", + PRECEDENCE_SESSION_RULESET + ); + this.dynamicRules = this.makeRuleset( + "_dynamic", + PRECEDENCE_DYNAMIC_RULESET + ); + this.enabledStaticRules = []; + + this.hasBlockPermission = extension.hasPermission("declarativeNetRequest"); + this.hasRulesWithTabIds = false; + this.hasRulesWithAllowAllRequests = false; + this.totalRulesCount = 0; + } + + get availableStaticRuleCount() { + return Math.max( + lazy.ExtensionDNRLimits.GUARANTEED_MINIMUM_STATIC_RULES - + this.enabledStaticRules.reduce( + (acc, ruleset) => acc + ruleset.rules.length, + 0 + ), + 0 + ); + } + + get enabledStaticRulesetIds() { + return this.enabledStaticRules.map(ruleset => ruleset.id); + } + + makeRuleset(rulesetId, rulesetPrecedence, rules = []) { + return new Ruleset(rulesetId, rulesetPrecedence, rules, this); + } + + setSessionRules(validatedSessionRules) { + let oldRulesCount = this.sessionRules.rules.length; + let newRulesCount = validatedSessionRules.length; + this.sessionRules.rules = validatedSessionRules; + this.totalRulesCount += newRulesCount - oldRulesCount; + this.hasRulesWithTabIds = !!this.sessionRules.rules.find(rule => { + return rule.condition.tabIds || rule.condition.excludedTabIds; + }); + this.#updateAllowAllRequestRules(); + NetworkIntegration.maybeUpdateTabIdChecker(); + } + + setDynamicRules(validatedDynamicRules) { + let oldRulesCount = this.dynamicRules.rules.length; + let newRulesCount = validatedDynamicRules.length; + this.dynamicRules.rules = validatedDynamicRules; + this.totalRulesCount += newRulesCount - oldRulesCount; + this.#updateAllowAllRequestRules(); + } + + /** + * Set the enabled static rulesets. + * + * @param {Array<{ id, rules }>} enabledStaticRulesets + * Array of objects including the ruleset id and rules. + * The order of the rulesets in the Array is expected to + * match the order of the rulesets in the extension manifest. + */ + setEnabledStaticRulesets(enabledStaticRulesets) { + const rulesets = []; + for (const [idx, { id, rules }] of enabledStaticRulesets.entries()) { + rulesets.push( + this.makeRuleset(id, idx + PRECEDENCE_STATIC_RULESETS_BASE, rules) + ); + } + const countRules = rulesets => + rulesets.reduce((sum, ruleset) => sum + ruleset.rules.length, 0); + const oldRulesCount = countRules(this.enabledStaticRules); + const newRulesCount = countRules(rulesets); + this.enabledStaticRules = rulesets; + this.totalRulesCount += newRulesCount - oldRulesCount; + this.#updateAllowAllRequestRules(); + } + + getSessionRules() { + return this.sessionRules.rules; + } + + getDynamicRules() { + return this.dynamicRules.rules; + } + + getRulesCount() { + return this.totalRulesCount; + } + + #updateAllowAllRequestRules() { + const filterAAR = rule => rule.action.type === "allowAllRequests"; + this.hasRulesWithAllowAllRequests = + this.sessionRules.rules.some(filterAAR) || + this.dynamicRules.rules.some(filterAAR) || + this.enabledStaticRules.some(ruleset => ruleset.rules.some(filterAAR)); + } +} + +function getRuleManager(extension, createIfMissing = true) { + let ruleManager = gRuleManagers.find(rm => rm.extension === extension); + if (!ruleManager && createIfMissing) { + if (extension.hasShutdown) { + throw new Error( + `Error on creating new DNR RuleManager after extension shutdown: ${extension.id}` + ); + } + ruleManager = new RuleManager(extension); + // The most recently installed extension gets priority, i.e. appears at the + // start of the gRuleManagers list. It is not yet possible to determine the + // installation time of a given Extension, so currently the last to + // instantiate a RuleManager claims the highest priority. + // TODO bug 1786059: order extensions by "installation time". + gRuleManagers.unshift(ruleManager); + if (gRuleManagers.length === 1) { + // The first DNR registration. + NetworkIntegration.register(); + } + } + return ruleManager; +} + +function clearRuleManager(extension) { + let i = gRuleManagers.findIndex(rm => rm.extension === extension); + if (i !== -1) { + gRuleManagers.splice(i, 1); + NetworkIntegration.maybeUpdateTabIdChecker(); + if (gRuleManagers.length === 0) { + // The last DNR registration. + NetworkIntegration.unregister(); + } + } +} + +/** + * Finds all matching rules for a request, optionally restricted to one + * extension. Used by declarativeNetRequest.testMatchOutcome. + * + * @param {object|RequestDetails} request + * @param {Extension} [extension] + * @returns {MatchedRule[]} + */ +function getMatchedRulesForRequest(request, extension) { + let requestDetails = new RequestDetails(request); + const { requestURI, initiatorURI } = requestDetails; + let ruleManagers = gRuleManagers; + if (extension) { + ruleManagers = ruleManagers.filter(rm => rm.extension === extension); + } + if ( + // NetworkIntegration.startDNREvaluation does not check requestURI, but we + // do that here to filter URIs that are obviously disallowed. In practice, + // anything other than http(s) is bogus and unsupported in DNR. + isRestrictedPrincipalURI(requestURI) || + // Equivalent to NetworkIntegration.startDNREvaluation's channel.canModify + // check, which excludes system requests and restricted domains. + WebExtensionPolicy.isRestrictedURI(requestURI) || + (initiatorURI && WebExtensionPolicy.isRestrictedURI(initiatorURI)) || + isRestrictedPrincipalURI(initiatorURI) + ) { + ruleManagers = []; + } + // While this simulated request is not really from another extension, apply + // the same access control checks from NetworkIntegration.startDNREvaluation + // for consistency. + if ( + !lazy.gMatchRequestsFromOtherExtensions && + initiatorURI?.schemeIs("moz-extension") + ) { + const extUuid = initiatorURI.host; + ruleManagers = ruleManagers.filter(rm => rm.extension.uuid === extUuid); + } + return RequestEvaluator.evaluateRequest(requestDetails, ruleManagers); +} + +/** + * Runs before any webRequest event is notified. Headers may be modified, but + * the request should not be canceled (see handleRequest instead). + * + * @param {ChannelWrapper} channel + * @param {string} kind - The name of the webRequest event. + */ +function beforeWebRequestEvent(channel, kind) { + try { + switch (kind) { + case "onBeforeRequest": + NetworkIntegration.startDNREvaluation(channel); + break; + case "onBeforeSendHeaders": + NetworkIntegration.onBeforeSendHeaders(channel); + break; + case "onHeadersReceived": + NetworkIntegration.onHeadersReceived(channel); + break; + } + } catch (e) { + Cu.reportError(e); + } +} + +/** + * Applies matching DNR rules, some of which may potentially cancel the request. + * + * @param {ChannelWrapper} channel + * @param {string} kind - The name of the webRequest event. + * @returns {boolean} Whether to ignore any responses from the webRequest API. + */ +function handleRequest(channel, kind) { + try { + if (kind === "onBeforeRequest") { + return NetworkIntegration.onBeforeRequest(channel); + } + } catch (e) { + Cu.reportError(e); + } + return false; +} + +async function initExtension(extension) { + // These permissions are NOT an OptionalPermission, so their status can be + // assumed to be constant for the lifetime of the extension. + if ( + extension.hasPermission("declarativeNetRequest") || + extension.hasPermission("declarativeNetRequestWithHostAccess") + ) { + if (extension.hasShutdown) { + throw new Error( + `Aborted ExtensionDNR.initExtension call, extension "${extension.id}" is not active anymore` + ); + } + extension.once("shutdown", () => clearRuleManager(extension)); + await lazy.ExtensionDNRStore.initExtension(extension); + } +} + +function ensureInitialized(extension) { + return (extension._dnrReady ??= initExtension(extension)); +} + +function validateManifestEntry(extension) { + const ruleResourcesArray = + extension.manifest.declarative_net_request.rule_resources; + + const getWarningMessage = msg => + `Warning processing declarative_net_request: ${msg}`; + + const { MAX_NUMBER_OF_STATIC_RULESETS } = lazy.ExtensionDNRLimits; + if (ruleResourcesArray.length > MAX_NUMBER_OF_STATIC_RULESETS) { + extension.manifestWarning( + getWarningMessage( + `Static rulesets are exceeding the MAX_NUMBER_OF_STATIC_RULESETS limit (${MAX_NUMBER_OF_STATIC_RULESETS}).` + ) + ); + } + + const seenRulesetIds = new Set(); + const seenRulesetPaths = new Set(); + const duplicatedRulesetIds = []; + const duplicatedRulesetPaths = []; + for (const [idx, { id, path }] of ruleResourcesArray.entries()) { + if (seenRulesetIds.has(id)) { + duplicatedRulesetIds.push({ idx, id }); + } + if (seenRulesetPaths.has(path)) { + duplicatedRulesetPaths.push({ idx, path }); + } + seenRulesetIds.add(id); + seenRulesetPaths.add(path); + } + + if (duplicatedRulesetIds.length) { + const errorDetails = duplicatedRulesetIds + .map(({ idx, id }) => `"${id}" at index ${idx}`) + .join(", "); + extension.manifestWarning( + getWarningMessage( + `Static ruleset ids should be unique, duplicated ruleset ids: ${errorDetails}.` + ) + ); + } + + if (duplicatedRulesetPaths.length) { + // NOTE: technically Chrome allows duplicated paths without any manifest + // validation warnings or errors, but if this happens it not unlikely to be + // actually a mistake in the manifest that may have been missed. + // + // In Firefox we decided to allow the same behavior to avoid introducing a chrome + // incompatibility, but we still warn about it to avoid extension developers + // to investigate more easily issue that may be due to duplicated rulesets + // paths. + const errorDetails = duplicatedRulesetPaths + .map(({ idx, path }) => `"${path}" at index ${idx}`) + .join(", "); + extension.manifestWarning( + getWarningMessage( + `Static rulesets paths are not unique, duplicated ruleset paths: ${errorDetails}.` + ) + ); + } + + const { MAX_NUMBER_OF_ENABLED_STATIC_RULESETS } = lazy.ExtensionDNRLimits; + + const enabledRulesets = ruleResourcesArray.filter(rs => rs.enabled); + if (enabledRulesets.length > MAX_NUMBER_OF_ENABLED_STATIC_RULESETS) { + const exceedingRulesetIds = enabledRulesets + .slice(MAX_NUMBER_OF_ENABLED_STATIC_RULESETS) + .map(ruleset => `"${ruleset.id}"`) + .join(", "); + extension.manifestWarning( + getWarningMessage( + `Enabled static rulesets are exceeding the MAX_NUMBER_OF_ENABLED_STATIC_RULESETS limit (${MAX_NUMBER_OF_ENABLED_STATIC_RULESETS}): ${exceedingRulesetIds}.` + ) + ); + } +} + +async function updateEnabledStaticRulesets(extension, updateRulesetOptions) { + await ensureInitialized(extension); + await lazy.ExtensionDNRStore.updateEnabledStaticRulesets( + extension, + updateRulesetOptions + ); +} + +async function updateDynamicRules(extension, updateRuleOptions) { + await ensureInitialized(extension); + await lazy.ExtensionDNRStore.updateDynamicRules(extension, updateRuleOptions); +} + +// exports used by the DNR API implementation. +export const ExtensionDNR = { + RuleValidator, + RuleQuotaCounter, + clearRuleManager, + ensureInitialized, + getMatchedRulesForRequest, + getRuleManager, + updateDynamicRules, + updateEnabledStaticRulesets, + validateManifestEntry, + beforeWebRequestEvent, + handleRequest, +}; diff --git a/toolkit/components/extensions/ExtensionDNRLimits.sys.mjs b/toolkit/components/extensions/ExtensionDNRLimits.sys.mjs new file mode 100644 index 0000000000..ac4cd79c44 --- /dev/null +++ b/toolkit/components/extensions/ExtensionDNRLimits.sys.mjs @@ -0,0 +1,59 @@ +/* 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/. */ + +// TODO(Bug 1803370): consider allowing changing DNR limits through about:config prefs). + +/** + * The minimum number of static rules guaranteed to an extension across its + * enabled static rulesets. Any rules above this limit will count towards the + * global static rule limit. + */ +const GUARANTEED_MINIMUM_STATIC_RULES = 30000; + +/** + * The maximum number of static Rulesets an extension can specify as part of + * the "rule_resources" manifest key. + * + * NOTE: this limit may be increased in the future, see https://github.com/w3c/webextensions/issues/318 + */ +const MAX_NUMBER_OF_STATIC_RULESETS = 50; + +/** + * The maximum number of static Rulesets an extension can enable at any one time. + * + * NOTE: this limit may be increased in the future, see https://github.com/w3c/webextensions/issues/318 + */ +const MAX_NUMBER_OF_ENABLED_STATIC_RULESETS = 10; + +/** + * The maximum number of dynamic and session rules an extension can add. + * NOTE: in the Firefox we are enforcing this limit to the session and dynamic rules count separately, + * instead of enforcing it to the rules count for both combined as the Chrome implementation does. + * + * NOTE: this limit may be increased in the future, see https://github.com/w3c/webextensions/issues/319 + */ +const MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES = 5000; + +/** + * The maximum number of regular expression rules that an extension can add. + * Session, dynamic and static rules have their own quota. + * + * TODO bug 1821033: Bump limit after optimizing regexFilter. + */ +const MAX_NUMBER_OF_REGEX_RULES = 1000; + +// TODO(Bug 1803370): allow extension to exceed the GUARANTEED_MINIMUM_STATIC_RULES limit. +// +// The maximum number of static rules exceeding the per-extension +// GUARANTEED_MINIMUM_STATIC_RULES across every extensions. +// +// const MAX_GLOBAL_NUMBER_OF_STATIC_RULES = 300000; + +export const ExtensionDNRLimits = { + GUARANTEED_MINIMUM_STATIC_RULES, + MAX_NUMBER_OF_STATIC_RULESETS, + MAX_NUMBER_OF_ENABLED_STATIC_RULESETS, + MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES, + MAX_NUMBER_OF_REGEX_RULES, +}; diff --git a/toolkit/components/extensions/ExtensionDNRStore.sys.mjs b/toolkit/components/extensions/ExtensionDNRStore.sys.mjs new file mode 100644 index 0000000000..7221e2cd3b --- /dev/null +++ b/toolkit/components/extensions/ExtensionDNRStore.sys.mjs @@ -0,0 +1,1700 @@ +/* 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/. */ + +import { ExtensionParent } from "resource://gre/modules/ExtensionParent.sys.mjs"; + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + Extension: "resource://gre/modules/Extension.sys.mjs", + ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs", + ExtensionDNRLimits: "resource://gre/modules/ExtensionDNRLimits.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + aomStartup: [ + "@mozilla.org/addons/addon-manager-startup;1", + "amIAddonManagerStartup", + ], +}); + +const LAST_UPDATE_TAG_PREF_PREFIX = "extensions.dnr.lastStoreUpdateTag."; + +const { DefaultMap, ExtensionError } = ExtensionUtils; +const { StartupCache } = ExtensionParent; + +// DNR Rules store subdirectory/file names and file extensions. +// +// NOTE: each extension's stored rules are stored in a per-extension file +// and stored rules filename is derived from the extension uuid assigned +// at install time. +const RULES_STORE_DIRNAME = "extension-dnr"; +const RULES_STORE_FILEEXT = ".json.lz4"; +const RULES_CACHE_FILENAME = "extensions-dnr.sc.lz4"; + +const requireTestOnlyCallers = () => { + if (!Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) { + throw new Error("This should only be called from XPCShell tests"); + } +}; + +/** + * Internal representation of the enabled static rulesets (used in StoreData + * and Store methods type signatures). + * + * @typedef {object} EnabledStaticRuleset + * @inner + * @property {number} idx + * Represent the position of the static ruleset in the manifest + * `declarative_net_request.rule_resources` array. + * @property {Array} rules + * Represent the array of the DNR rules associated with the static + * ruleset. + */ + +// Class defining the format of the data stored into the per-extension files +// managed by RulesetsStore. +// +// StoreData instances are saved in the profile extension-dir subdirectory as +// lz4-compressed JSON files, only the ruleset_id is stored on disk for the +// enabled static rulesets (while the actual rules would need to be loaded back +// from the related rules JSON files part of the extension assets). +class StoreData { + // NOTE: Update schema version upgrade handling code in `RulesetsStore.#readData` + // along with bumps to the schema version here. + static VERSION = 1; + + static getLastUpdateTagPref(extensionUUID) { + return `${LAST_UPDATE_TAG_PREF_PREFIX}${extensionUUID}`; + } + + static getLastUpdateTag(extensionUUID) { + return Services.prefs.getCharPref( + this.getLastUpdateTagPref(extensionUUID), + null + ); + } + + static storeLastUpdateTag(extensionUUID, lastUpdateTag) { + Services.prefs.setCharPref( + this.getLastUpdateTagPref(extensionUUID), + lastUpdateTag + ); + } + + static clearLastUpdateTagPref(extensionUUID) { + Services.prefs.clearUserPref(this.getLastUpdateTagPref(extensionUUID)); + } + + static isStaleCacheEntry(extensionUUID, cacheStoreData) { + return ( + // Drop the cache entry if the data stored doesn't match the current + // StoreData schema version (this shouldn't happen unless the file + // have been manually restored by the user from an older firefox version). + cacheStoreData.schemaVersion !== this.VERSION || + // Drop the cache entry if the lastUpdateTag from the cached data entry + // doesn't match the lastUpdateTag recorded in the prefs, the tag is applied + // with a per-extension granularity to reduce the chances of cache misses + // last update on the cached data for an unrelated extensions did not make it + // to disk). + cacheStoreData.lastUpdateTag != this.getLastUpdateTag(extensionUUID) + ); + } + + #extUUID; + #initialLastUdateTag; + #temporarilyInstalled; + + /** + * @param {Extension} extension + * The extension the StoreData is associated to. + * @param {object} params + * @param {string} [params.extVersion] + * extension version + * @param {string} [params.lastUpdateTag] + * a tag associated to the data. It is only passed when we are loading the data + * from the StartupCache file, while a new tag uuid string will be generated + * for brand new data (and then new ones generated on each calls to the `updateRulesets` + * method). + * @param {number} [params.schemaVersion=StoreData.VERSION] + * file schema version + * @param {Map} [params.staticRulesets=new Map()] + * map of the enabled static rulesets by ruleset_id, as resolved by + * `Store.prototype.#getManifestStaticRulesets`. + * NOTE: This map is converted in an array of the ruleset_id strings when the StoreData + * instance is being stored on disk (see `toJSON` method) and then converted back to a Map + * by `Store.prototype.#getManifestStaticRulesets` when the data is loaded back from disk. + * @param {Array} [params.dynamicRuleset=[]] + * array of dynamic rules stored by the extension. + */ + constructor( + extension, + { + extVersion, + lastUpdateTag, + dynamicRuleset, + staticRulesets, + schemaVersion, + } = {} + ) { + if (!(extension instanceof lazy.Extension)) { + throw new Error("Missing mandatory extension parameter"); + } + this.schemaVersion = schemaVersion || StoreData.VERSION; + this.extVersion = extVersion ?? extension.version; + this.#extUUID = extension.uuid; + // Used to skip storing the data in the startupCache or storing the lastUpdateTag in + // the about:config prefs. + this.#temporarilyInstalled = extension.temporarilyInstalled; + // The lastUpdateTag gets set (and updated) by calls to updateRulesets. + this.lastUpdateTag = undefined; + this.#initialLastUdateTag = lastUpdateTag; + this.#updateRulesets({ + staticRulesets: staticRulesets ?? new Map(), + dynamicRuleset: dynamicRuleset ?? [], + lastUpdateTag, + }); + } + + isFromStartupCache() { + return this.#initialLastUdateTag == this.lastUpdateTag; + } + + isFromTemporarilyInstalled() { + return this.#temporarilyInstalled; + } + + get isEmpty() { + return !this.staticRulesets.size && !this.dynamicRuleset.length; + } + + /** + * Updates the static and or dynamic rulesets stored for the related + * extension. + * + * NOTE: This method also: + * - regenerates the lastUpdateTag associated as an unique identifier + * of the revision for the stored data (used to detect stale startup + * cache data) + * - stores the lastUpdateTag into an about:config pref associated to + * the extension uuid (also used as part of detecting stale startup + * cache data), unless the extension is installed temporarily. + * + * @param {object} params + * @param {Map} [params.staticRulesets] + * optional new updated Map of static rulesets + * (static rulesets are unchanged if not passed). + * @param {Array} [params.dynamicRuleset=[]] + * optional array of updated dynamic rules + * (dynamic rules are unchanged if not passed). + */ + updateRulesets({ staticRulesets, dynamicRuleset } = {}) { + let currentUpdateTag = this.lastUpdateTag; + let lastUpdateTag = this.#updateRulesets({ + staticRulesets, + dynamicRuleset, + }); + + // Tag each cache data entry with a value synchronously stored in an + // about:config prefs, if on a browser restart the tag in the startupCache + // data entry doesn't match the one in the about:config pref then the startup + // cache entry is dropped as stale (assuming an issue prevented the updated + // cache data to be written on disk, e.g. browser crash, failure on writing + // on disk etc.), each entry is tagged separately to decrease the chances + // of cache misses on unrelated cache data entries if only a few extension + // got stale data in the startup cache file. + if ( + !this.isFromTemporarilyInstalled() && + currentUpdateTag != lastUpdateTag + ) { + StoreData.storeLastUpdateTag(this.#extUUID, lastUpdateTag); + } + } + + #updateRulesets({ + staticRulesets = null, + dynamicRuleset = null, + lastUpdateTag = Services.uuid.generateUUID().toString(), + } = {}) { + if (staticRulesets) { + this.staticRulesets = staticRulesets; + } + + if (dynamicRuleset) { + this.dynamicRuleset = dynamicRuleset; + } + + if (staticRulesets || dynamicRuleset) { + this.lastUpdateTag = lastUpdateTag; + } + + return this.lastUpdateTag; + } + + // This method is used to convert the data in the format stored on disk + // as a JSON file. + toJSON() { + const data = { + schemaVersion: this.schemaVersion, + extVersion: this.extVersion, + // Only store the array of the enabled ruleset_id in the set of data + // persisted in a JSON form. + staticRulesets: this.staticRulesets + ? Array.from(this.staticRulesets.entries(), ([id, _ruleset]) => id) + : undefined, + dynamicRuleset: this.dynamicRuleset, + }; + return data; + } + + // This method is used to convert the data back to a StoreData class from + // the format stored on disk as a JSON file. + // NOTE: this method should be kept in sync with toJSON and make sure that + // we do deserialize the same property we are serializing into the JSON file. + static fromJSON(paramsFromJSON, extension) { + let { schemaVersion, extVersion, staticRulesets, dynamicRuleset } = + paramsFromJSON; + return new StoreData(extension, { + schemaVersion, + extVersion, + staticRulesets, + dynamicRuleset, + }); + } +} + +class Queue { + #tasks = []; + #runningTask = null; + #closed = false; + + get hasPendingTasks() { + return !!this.#runningTask || !!this.#tasks.length; + } + + get isClosed() { + return this.#closed; + } + + async close() { + if (this.#closed) { + const lastTask = this.#tasks[this.#tasks.length - 1]; + return lastTask?.deferred.promise; + } + const drainedQueuePromise = this.queueTask(() => {}); + this.#closed = true; + return drainedQueuePromise; + } + + queueTask(callback) { + if (this.#closed) { + throw new Error("Unexpected queueTask call on closed queue"); + } + const deferred = Promise.withResolvers(); + this.#tasks.push({ callback, deferred }); + // Run the queued task right away if there isn't one already running. + if (!this.#runningTask) { + this.#runNextTask(); + } + return deferred.promise; + } + + async #runNextTask() { + if (!this.#tasks.length) { + this.#runningTask = null; + return; + } + + this.#runningTask = this.#tasks.shift(); + const { callback, deferred } = this.#runningTask; + try { + let result = callback(); + if (result instanceof Promise) { + result = await result; + } + deferred.resolve(result); + } catch (err) { + deferred.reject(err); + } + + this.#runNextTask(); + } +} + +/** + * Class managing the rulesets persisted across browser sessions. + * + * The data gets stored in two per-extension files: + * + * - `ProfD/extension-dnr/EXT_UUID.json.lz4` is a lz4-compressed JSON file that is expected to include + * the ruleset ids for the enabled static rulesets and the dynamic rules. + * + * All browser data stored is expected to be persisted across browser updates, but the enabled static ruleset + * ids are expected to be reset and reinitialized from the extension manifest.json properties when the + * add-on is being updated (either downgraded or upgraded). + * + * In case of unexpected data schema downgrades (which may be hit if the user explicit pass --allow-downgrade + * while using an older browser version than the one used when the data has been stored), the entire stored + * data is reset and re-initialized from scratch based on the manifest.json file. + */ +class RulesetsStore { + constructor() { + // Map + this._data = new Map(); + // Map> + this._dataPromises = new Map(); + // Map> + this._savePromises = new Map(); + // Map + this._dataUpdateQueues = new DefaultMap(() => new Queue()); + // Promise to await on to ensure the store parent directory exist + // (the parent directory is shared by all extensions and so we only need one). + this._ensureStoreDirectoryPromise = null; + // Promise to await on to ensure (there is only one startupCache file for all + // extensions and so we only need one): + // - the cache file parent directory exist + // - the cache file data has been loaded (if any was available and matching + // the last DNR data stored on disk) + // - the cache file data has been saved. + this._ensureCacheDirectoryPromise = null; + this._ensureCacheLoaded = null; + this._saveCacheTask = null; + // Map of the raw data read from the startupCache. + // Map + this._startupCacheData = new Map(); + } + + /** + * Wait for the startup cache data to be stored on disk. + * + * NOTE: Only meant to be used in xpcshell tests. + * + * @returns {Promise} + */ + async waitSaveCacheDataForTesting() { + requireTestOnlyCallers(); + if (this._saveCacheTask) { + if (this._saveCacheTask.isRunning) { + await this._saveCacheTask._runningPromise; + } + // #saveCacheDataNow() may schedule another save if anything has changed in between + while (this._saveCacheTask.isArmed) { + this._saveCacheTask.disarm(); + await this.#saveCacheDataNow(); + } + } + } + + /** + * Remove store file for the given extension UUId from disk (used to remove all + * data on addon uninstall). + * + * @param {string} extensionUUID + * @returns {Promise} + */ + async clearOnUninstall(extensionUUID) { + // TODO(Bug 1825510): call scheduleCacheDataSave to update the startup cache data + // stored on disk, but skip it if it is late in the application shutdown. + StoreData.clearLastUpdateTagPref(extensionUUID); + const storeFile = this.#getStoreFilePath(extensionUUID); + + // TODO(Bug 1803363): consider collect telemetry on DNR store file removal errors. + // TODO: consider catch and report unexpected errors + await IOUtils.remove(storeFile, { ignoreAbsent: true }); + } + + /** + * Load (or initialize) the store file data for the given extension and + * return an Array of the dynamic rules. + * + * @param {Extension} extension + * + * @returns {Promise>} + * Resolve to a reference to the dynamic rules array. + * NOTE: the caller should never mutate the content of this array, + * updates to the dynamic rules should always go through + * the `updateDynamicRules` method. + */ + async getDynamicRules(extension) { + let data = await this.#getDataPromise(extension); + return data.dynamicRuleset; + } + + /** + * Load (or initialize) the store file data for the given extension and + * return a Map of the enabled static rulesets and their related rules. + * + * - if the extension manifest doesn't have any static rulesets declared in the + * manifest, returns null + * + * - if the extension version from the stored data doesn't match the current + * extension versions, the static rules are being reloaded from the manifest. + * + * @param {Extension} extension + * + * @returns {Promise>} + * Resolves to a reference to the static rulesets map. + * NOTE: the caller should never mutate the content of this map, + * updates to the enabled static rulesets should always go through + * the `updateEnabledStaticRulesets` method. + */ + async getEnabledStaticRulesets(extension) { + let data = await this.#getDataPromise(extension); + return data.staticRulesets; + } + + async getAvailableStaticRuleCount(extension) { + const { GUARANTEED_MINIMUM_STATIC_RULES } = lazy.ExtensionDNRLimits; + + const ruleResources = + extension.manifest.declarative_net_request?.rule_resources; + // TODO: return maximum rules count when no static rules is listed in the manifest? + if (!Array.isArray(ruleResources)) { + return GUARANTEED_MINIMUM_STATIC_RULES; + } + + const enabledRulesets = await this.getEnabledStaticRulesets(extension); + const enabledRulesCount = Array.from(enabledRulesets.values()).reduce( + (acc, ruleset) => acc + ruleset.rules.length, + 0 + ); + + return GUARANTEED_MINIMUM_STATIC_RULES - enabledRulesCount; + } + + /** + * Initialize the DNR store for the given extension, it does also queue the task to make + * sure that extension DNR API calls triggered while the initialization may still be + * in progress will be executed sequentially. + * + * @param {Extension} extension + * + * @returns {Promise} A promise resolved when the async initialization has been + * completed. + */ + async initExtension(extension) { + const ensureExtensionRunning = () => { + if (extension.hasShutdown) { + throw new Error( + `DNR store initialization abort, extension is already shutting down: ${extension.id}` + ); + } + }; + + // Make sure we wait for pending save promise to have been + // completed and old data unloaded (this may be hit if an + // extension updates or reloads while there are still + // rules updates being processed and then stored on disk). + ensureExtensionRunning(); + if (this._savePromises.has(extension.uuid)) { + Cu.reportError( + `Unexpected pending save task while reading DNR data after an install/update of extension "${extension.id}"` + ); + // await pending saving data to be saved and unloaded. + await this.#unloadData(extension.uuid); + // Make sure the extension is still running after awaiting on + // unloadData to be completed. + ensureExtensionRunning(); + } + + return this._dataUpdateQueues.get(extension.uuid).queueTask(() => { + return this.#initExtension(extension); + }); + } + + /** + * Update the dynamic rules, queue changes to prevent races between calls + * that may be triggered while an update is still in process. + * + * @param {Extension} extension + * @param {object} params + * @param {Array} [params.removeRuleIds=[]] + * @param {Array} [params.addRules=[]] + * + * @returns {Promise} A promise resolved when the dynamic rules async update has + * been completed. + */ + async updateDynamicRules(extension, { removeRuleIds, addRules }) { + return this._dataUpdateQueues.get(extension.uuid).queueTask(() => { + return this.#updateDynamicRules(extension, { + removeRuleIds, + addRules, + }); + }); + } + + /** + * Update the enabled rulesets, queue changes to prevent races between calls + * that may be triggered while an update is still in process. + * + * @param {Extension} extension + * @param {object} params + * @param {Array} [params.disableRulesetIds=[]] + * @param {Array} [params.enableRulesetIds=[]] + * + * @returns {Promise} A promise resolved when the enabled static rulesets async + * update has been completed. + */ + async updateEnabledStaticRulesets( + extension, + { disableRulesetIds, enableRulesetIds } + ) { + return this._dataUpdateQueues.get(extension.uuid).queueTask(() => { + return this.#updateEnabledStaticRulesets(extension, { + disableRulesetIds, + enableRulesetIds, + }); + }); + } + + /** + * Update DNR RulesetManager rules to match the current DNR rules enabled in the DNRStore. + * + * @param {Extension} extension + * @param {object} [params] + * @param {boolean} [params.updateStaticRulesets=true] + * @param {boolean} [params.updateDynamicRuleset=true] + */ + updateRulesetManager( + extension, + { updateStaticRulesets = true, updateDynamicRuleset = true } = {} + ) { + if (!updateStaticRulesets && !updateDynamicRuleset) { + return; + } + + if ( + !this._dataPromises.has(extension.uuid) || + !this._data.has(extension.uuid) + ) { + throw new Error( + `Unexpected call to updateRulesetManager before DNR store was fully initialized for extension "${extension.id}"` + ); + } + const data = this._data.get(extension.uuid); + const ruleManager = lazy.ExtensionDNR.getRuleManager(extension); + + if (updateStaticRulesets) { + let staticRulesetsMap = data.staticRulesets; + // Convert into array and ensure order match the order of the rulesets in + // the extension manifest. + const enabledStaticRules = []; + // Order the static rulesets by index of rule_resources in manifest.json. + const orderedRulesets = Array.from(staticRulesetsMap.entries()).sort( + ([_idA, rsA], [_idB, rsB]) => rsA.idx - rsB.idx + ); + for (const [rulesetId, ruleset] of orderedRulesets) { + enabledStaticRules.push({ id: rulesetId, rules: ruleset.rules }); + } + ruleManager.setEnabledStaticRulesets(enabledStaticRules); + } + + if (updateDynamicRuleset) { + ruleManager.setDynamicRules(data.dynamicRuleset); + } + } + + /** + * Return the store file path for the given the extension's uuid and the cache + * file with startupCache data for all the extensions. + * + * @param {string} extensionUUID + * @returns {{ storeFile: string | void, cacheFile: string}} + * An object including the full paths to both the per-extension store file + * for the given extension UUID and the full path to the single startupCache + * file (which would include the cached data for all the extensions). + */ + getFilePaths(extensionUUID) { + return { + storeFile: this.#getStoreFilePath(extensionUUID), + cacheFile: this.#getCacheFilePath(), + }; + } + + /** + * Save the data for the given extension on disk. + * + * @param {Extension} extension + */ + async save(extension) { + const { uuid, id } = extension; + let savePromise = this._savePromises.get(uuid); + + if (!savePromise) { + savePromise = this.#saveNow(uuid, id); + this._savePromises.set(uuid, savePromise); + IOUtils.profileBeforeChange.addBlocker( + `Flush WebExtension DNR RulesetsStore: ${id}`, + savePromise + ); + } + + return savePromise; + } + + /** + * Register an onClose shutdown handler to cleanup the data from memory when + * the extension is shutting down. + * + * @param {Extension} extension + * @returns {void} + */ + unloadOnShutdown(extension) { + if (extension.hasShutdown) { + throw new Error( + `DNR store registering an extension shutdown handler too late, the extension is already shutting down: ${extension.id}` + ); + } + + const extensionUUID = extension.uuid; + extension.callOnClose({ + close: async () => this.#unloadData(extensionUUID), + }); + } + + /** + * Return a branch new StoreData instance given an extension. + * + * @param {Extension} extension + * @returns {StoreData} + */ + #getDefaults(extension) { + return new StoreData(extension, { extVersion: extension.version }); + } + + /** + * Return the cache file path. + * + * @returns {string} + * The absolute path to the startupCache file. + */ + #getCacheFilePath() { + // When the application version changes, this file is removed by + // RemoveComponentRegistries in nsAppRunner.cpp. + return PathUtils.join( + Services.dirsvc.get("ProfLD", Ci.nsIFile).path, + "startupCache", + RULES_CACHE_FILENAME + ); + } + + /** + * Return the path to the store file given the extension's uuid. + * + * @param {string} extensionUUID + * @returns {string} Full path to the store file for the extension. + */ + #getStoreFilePath(extensionUUID) { + return PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + RULES_STORE_DIRNAME, + `${extensionUUID}${RULES_STORE_FILEEXT}` + ); + } + + #ensureCacheDirectory() { + if (this._ensureCacheDirectoryPromise === null) { + const file = this.#getCacheFilePath(); + this._ensureCacheDirectoryPromise = IOUtils.makeDirectory( + PathUtils.parent(file), + { + ignoreExisting: true, + createAncestors: true, + } + ); + } + return this._ensureCacheDirectoryPromise; + } + + #ensureStoreDirectory(extensionUUID) { + // Currently all extensions share the same directory, so we can re-use this promise across all + // `#ensureStoreDirectory` calls. + if (this._ensureStoreDirectoryPromise === null) { + const file = this.#getStoreFilePath(extensionUUID); + this._ensureStoreDirectoryPromise = IOUtils.makeDirectory( + PathUtils.parent(file), + { + ignoreExisting: true, + createAncestors: true, + } + ); + } + return this._ensureStoreDirectoryPromise; + } + + #getDataPromise(extension) { + let dataPromise = this._dataPromises.get(extension.uuid); + if (!dataPromise) { + if (extension.hasShutdown) { + throw new Error( + `DNR store data loading aborted, the extension is already shutting down: ${extension.id}` + ); + } + + this.unloadOnShutdown(extension); + dataPromise = this.#readData(extension); + this._dataPromises.set(extension.uuid, dataPromise); + } + return dataPromise; + } + + /** + * Reads the store file for the given extensions and all rules + * for the enabled static ruleset ids listed in the store file. + * + * @typedef {string} ruleset_id + * + * @param {Extension} extension + * @param {object} [options] + * @param {Array} [options.enabledRulesetIds] + * An optional array of enabled ruleset ids to be loaded + * (used to load a specific group of static rulesets, + * either when the list of static rules needs to be recreated based + * on the enabled rulesets, or when the extension is + * changing the enabled rulesets using the `updateEnabledRulesets` + * API method). + * @param {boolean} [options.isUpdateEnabledRulesets] + * Whether this is a call by updateEnabledRulesets. When true, + * `enabledRulesetIds` contains the IDs of disabled rulesets that + * should be enabled. Already-enabled rulesets are not included in + * `enabledRulesetIds`. + * @param {import("ExtensionDNR.sys.mjs").RuleQuotaCounter} [options.ruleQuotaCounter] + * The counter of already-enabled rules that are not part of + * `enabledRulesetIds`. Set when `isUpdateEnabledRulesets` is true. + * This method may mutate its internal counters. + * @returns {Promise>} + * map of the enabled static rulesets by ruleset_id. + */ + async #getManifestStaticRulesets( + extension, + { + enabledRulesetIds = null, + isUpdateEnabledRulesets = false, + ruleQuotaCounter, + } = {} + ) { + // Map} + const rulesets = new Map(); + + const ruleResources = + extension.manifest.declarative_net_request?.rule_resources; + if (!Array.isArray(ruleResources)) { + return rulesets; + } + + if (!isUpdateEnabledRulesets) { + ruleQuotaCounter = new lazy.ExtensionDNR.RuleQuotaCounter( + /* isStaticRulesets */ true + ); + } + + const { + MAX_NUMBER_OF_ENABLED_STATIC_RULESETS, + // Warnings on MAX_NUMBER_OF_STATIC_RULESETS are already + // reported (see ExtensionDNR.validateManifestEntry, called + // from the DNR API onManifestEntry callback). + } = lazy.ExtensionDNRLimits; + + for (let [idx, { id, enabled, path }] of ruleResources.entries()) { + // If passed enabledRulesetIds is used to determine if the enabled + // rules in the manifest should be overridden from the list of + // enabled static rulesets stored on disk. + if (Array.isArray(enabledRulesetIds)) { + enabled = enabledRulesetIds.includes(id); + } + + // Duplicated ruleset ids are validated as part of the JSONSchema validation, + // here we log a warning to signal that we are ignoring it if when the validation + // error isn't strict (e.g. for non temporarily installed, which shouldn't normally + // hit in the long run because we can also validate it before signing the extension). + if (rulesets.has(id)) { + Cu.reportError( + `Disabled static ruleset with duplicated ruleset_id "${id}"` + ); + continue; + } + + if (enabled && rulesets.size >= MAX_NUMBER_OF_ENABLED_STATIC_RULESETS) { + // This is technically reported from the manifest validation, as a warning + // on extension installed non temporarily, and so checked and logged here + // in case we are hitting it while loading the enabled rulesets. + Cu.reportError( + `Ignoring enabled static ruleset exceeding the MAX_NUMBER_OF_ENABLED_STATIC_RULESETS limit (${MAX_NUMBER_OF_ENABLED_STATIC_RULESETS}): ruleset_id "${id}" (extension: "${extension.id}")` + ); + continue; + } + + const readJSONStartTime = Cu.now(); + const rawRules = + enabled && + (await fetch(path) + .then(res => res.json()) + .catch(err => { + Cu.reportError(err); + enabled = false; + extension.packagingError( + `Reading declarative_net_request static rules file ${path}: ${err.message}` + ); + })); + ChromeUtils.addProfilerMarker( + "ExtensionDNRStore", + { startTime: readJSONStartTime }, + `StaticRulesetsReadJSON, addonId: ${extension.id}` + ); + + // Skip rulesets that are not enabled or can't be enabled (e.g. if we got error on loading or + // parsing the rules JSON file). + if (!enabled) { + continue; + } + + if (!Array.isArray(rawRules)) { + extension.packagingError( + `Reading declarative_net_request static rules file ${path}: rules file must contain an Array of rules` + ); + continue; + } + + // TODO(Bug 1803369): consider to only report the errors and warnings about invalid static rules for + // temporarily installed extensions (chrome only shows them for unpacked extensions). + const logRuleValidationError = err => extension.packagingWarning(err); + + const validatedRules = this.#getValidatedRules(extension, id, rawRules, { + logRuleValidationError, + }); + + // NOTE: this is currently only accounting for valid rules because + // only the valid rules will be actually be loaded. Reconsider if + // we should instead also account for the rules that have been + // ignored as invalid. + try { + ruleQuotaCounter.tryAddRules(id, validatedRules); + } catch (e) { + // If this is an API call (updateEnabledRulesets), just propagate the + // error. Otherwise we are intializing the extension and should just + // ignore the ruleset while reporting the error. + if (isUpdateEnabledRulesets) { + throw e; + } + // TODO(Bug 1803363): consider collect telemetry. + Cu.reportError( + `Ignoring static ruleset "${id}" in extension "${extension.id}" because: ${e.message}` + ); + continue; + } + + rulesets.set(id, { idx, rules: validatedRules }); + } + + return rulesets; + } + + /** + * Returns an array of validated and normalized Rule instances given an array + * of raw rules data (e.g. in form of plain objects read from the static rules + * JSON files or the dynamicRuleset property from the extension DNR store data). + * + * @typedef {import("ExtensionDNR.sys.mjs").Rule} Rule + * + * @param {Extension} extension + * @param {string} rulesetId + * @param {Array} rawRules + * @param {object} options + * @param {Function} [options.logRuleValidationError] + * an optional callback to call for logging the + * validation errors, defaults to use Cu.reportError + * (but getManifestStaticRulesets overrides it to use + * extensions.packagingWarning instead). + * + * @returns {Array} + */ + #getValidatedRules( + extension, + rulesetId, + rawRules, + { logRuleValidationError = err => Cu.reportError(err) } = {} + ) { + const startTime = Cu.now(); + const validatedRulesTimerId = + Glean.extensionsApisDnr.validateRulesTime.start(); + try { + const ruleValidator = new lazy.ExtensionDNR.RuleValidator([]); + // Normalize rules read from JSON. + const validationContext = { + url: extension.baseURI.spec, + principal: extension.principal, + logError: logRuleValidationError, + preprocessors: {}, + manifestVersion: extension.manifestVersion, + }; + + // TODO(Bug 1803369): consider to also include the rule id if one was available. + const getInvalidRuleMessage = (ruleIndex, msg) => + `Invalid rule at index ${ruleIndex} from ruleset "${rulesetId}", ${msg}`; + + for (const [rawIndex, rawRule] of rawRules.entries()) { + try { + const normalizedRule = lazy.Schemas.normalize( + rawRule, + "declarativeNetRequest.Rule", + validationContext + ); + if (normalizedRule.value) { + ruleValidator.addRules([normalizedRule.value]); + } else { + logRuleValidationError( + getInvalidRuleMessage( + rawIndex, + normalizedRule.error ?? "Unexpected undefined rule" + ) + ); + } + } catch (err) { + Cu.reportError(err); + logRuleValidationError( + getInvalidRuleMessage(rawIndex, "An unexpected error occurred") + ); + } + } + + // TODO(Bug 1803369): consider including an index in the invalid rules warnings. + if (ruleValidator.getFailures().length) { + logRuleValidationError( + `Invalid rules found in ruleset "${rulesetId}": ${ruleValidator + .getFailures() + .map(f => f.message) + .join(", ")}` + ); + } + + return ruleValidator.getValidatedRules(); + } finally { + ChromeUtils.addProfilerMarker( + "ExtensionDNRStore", + { startTime }, + `#getValidatedRules, addonId: ${extension.id}` + ); + Glean.extensionsApisDnr.validateRulesTime.stopAndAccumulate( + validatedRulesTimerId + ); + } + } + + #hasInstallOrUpdateStartupReason(extension) { + switch (extension.startupReason) { + case "ADDON_INSTALL": + case "ADDON_UPGRADE": + case "ADDON_DOWNGRADE": + return true; + } + + return false; + } + + /** + * Load and add the DNR stored rules to the RuleManager instance for the given + * extension. + * + * @param {Extension} extension + * @returns {Promise} + */ + async #initExtension(extension) { + // - on new installs the stored rules should be recreated from scratch + // (and any stale previously stored data to be ignored) + // - on upgrades/downgrades: + // - the dynamic rules are expected to be preserved + // - the static rules are expected to be refreshed from the new + // manifest data (also the enabled rulesets are expected to be + // reset to the state described in the manifest) + // + // TODO(Bug 1803369): consider also setting to true if the extension is installed temporarily. + if (this.#hasInstallOrUpdateStartupReason(extension)) { + // Reset the stored static rules on addon updates. + await StartupCache.delete(extension, ["dnr", "hasEnabledStaticRules"]); + } + + const hasEnabledStaticRules = await StartupCache.get( + extension, + ["dnr", "hasEnabledStaticRules"], + async () => { + const staticRulesets = await this.getEnabledStaticRulesets(extension); + + return staticRulesets.size; + } + ); + const hasDynamicRules = await StartupCache.get( + extension, + ["dnr", "hasDynamicRules"], + async () => { + const dynamicRuleset = await this.getDynamicRules(extension); + + return dynamicRuleset.length; + } + ); + + if (hasEnabledStaticRules || hasDynamicRules) { + const data = await this.#getDataPromise(extension); + if (!data.isFromStartupCache() && !data.isFromTemporarilyInstalled()) { + this.scheduleCacheDataSave(); + } + if (extension.hasShutdown) { + return; + } + this.updateRulesetManager(extension, { + updateStaticRulesets: hasEnabledStaticRules, + updateDynamicRuleset: hasDynamicRules, + }); + } + } + + #promiseStartupCacheLoaded() { + if (!this._ensureCacheLoaded) { + if (this._data.size) { + return Promise.reject( + new Error( + "Unexpected non-empty DNRStore data. DNR startupCache data load aborted." + ) + ); + } + + const startTime = Cu.now(); + const timerId = Glean.extensionsApisDnr.startupCacheReadTime.start(); + this._ensureCacheLoaded = (async () => { + const cacheFilePath = this.#getCacheFilePath(); + const { buffer, byteLength } = await IOUtils.read(cacheFilePath); + Glean.extensionsApisDnr.startupCacheReadSize.accumulate(byteLength); + const decodedData = lazy.aomStartup.decodeBlob(buffer); + const emptyOrCorruptedCache = !(decodedData?.cacheData instanceof Map); + if (emptyOrCorruptedCache) { + Cu.reportError( + `Unexpected corrupted DNRStore startupCache data. DNR startupCache data load dropped.` + ); + // Remove the cache file right away on corrupted (unexpected empty) + // or obsolete cache content. + await IOUtils.remove(cacheFilePath, { ignoreAbsent: true }); + return; + } + if (this._data.size) { + Cu.reportError( + `Unexpected non-empty DNRStore data. DNR startupCache data load dropped.` + ); + return; + } + for (const [ + extUUID, + cacheStoreData, + ] of decodedData.cacheData.entries()) { + if (StoreData.isStaleCacheEntry(extUUID, cacheStoreData)) { + StoreData.clearLastUpdateTagPref(extUUID); + continue; + } + // TODO(Bug 1825510): schedule a task long enough after startup to detect and + // remove unused entries in the _startupCacheData Map sooner. + this._startupCacheData.set(extUUID, { + extUUID: extUUID, + ...cacheStoreData, + }); + } + })() + .catch(err => { + // TODO: collect telemetry on unexpected cache load failures. + if (!DOMException.isInstance(err) || err.name !== "NotFoundError") { + Cu.reportError(err); + } + }) + .finally(() => { + ChromeUtils.addProfilerMarker( + "ExtensionDNRStore", + { startTime }, + "_ensureCacheLoaded" + ); + Glean.extensionsApisDnr.startupCacheReadTime.stopAndAccumulate( + timerId + ); + }); + } + + return this._ensureCacheLoaded; + } + + /** + * Read the stored data for the given extension, either from: + * - store file (if available and not detected as a data schema downgrade) + * - manifest file and packaged ruleset JSON files (if there was no valid stored data found) + * + * This private method is only called from #getDataPromise, which caches the return value + * in memory. + * + * @param {Extension} extension + * + * @returns {Promise} + */ + async #readData(extension) { + const startTime = Cu.now(); + try { + let result; + // Try to load data from the startupCache. + if (extension.startupReason === "APP_STARTUP") { + result = await this.#readStoreDataFromStartupCache(extension); + } + // Fallback to load the data stored in the json file. + result ??= await this.#readStoreData(extension); + + // Reset the stored data if a data schema version downgrade has been + // detected (this should only be hit on downgrades if the user have + // also explicitly passed --allow-downgrade CLI option). + if (result && result.schemaVersion > StoreData.VERSION) { + Cu.reportError( + `Unsupport DNR store schema version downgrade: resetting stored data for ${extension.id}` + ); + result = null; + } + + // Use defaults and extension manifest if no data stored was found + // (or it got reset due to an unsupported profile downgrade being detected). + if (!result) { + // We don't have any data stored, load the static rules from the manifest. + result = this.#getDefaults(extension); + // Initialize the staticRules data from the manifest. + result.updateRulesets({ + staticRulesets: await this.#getManifestStaticRulesets(extension), + }); + } + + // TODO: handle DNR store schema changes here when the StoreData.VERSION is being bumped. + // if (result && result.version < StoreData.VERSION) { + // result = this.upgradeStoreDataSchema(result); + // } + + // The extension has already shutting down and we may already got past + // the unloadData cleanup (given that there is still a promise in + // the _dataPromises Map). + if (extension.hasShutdown && !this._dataPromises.has(extension.uuid)) { + throw new Error( + `DNR store data loading aborted, the extension is already shutting down: ${extension.id}` + ); + } + + this._data.set(extension.uuid, result); + + return result; + } finally { + ChromeUtils.addProfilerMarker( + "ExtensionDNRStore", + { startTime }, + `readData, addonId: ${extension.id}` + ); + } + } + + // Convert extension entries in the startCache map back to StoreData instances + // (because the StoreData instances get converted into plain objects when + // serialized into the startupCache structured clone blobs). + async #readStoreDataFromStartupCache(extension) { + await this.#promiseStartupCacheLoaded(); + + if (!this._startupCacheData.has(extension.uuid)) { + Glean.extensionsApisDnr.startupCacheEntries.miss.add(1); + return; + } + + const extCacheData = this._startupCacheData.get(extension.uuid); + this._startupCacheData.delete(extension.uuid); + + if (extCacheData.extVersion != extension.version) { + StoreData.clearLastUpdateTagPref(extension.uuid); + Glean.extensionsApisDnr.startupCacheEntries.miss.add(1); + return; + } + + Glean.extensionsApisDnr.startupCacheEntries.hit.add(1); + for (const ruleset of extCacheData.staticRulesets.values()) { + ruleset.rules = ruleset.rules.map(rule => + lazy.ExtensionDNR.RuleValidator.deserializeRule(rule) + ); + } + extCacheData.dynamicRuleset = extCacheData.dynamicRuleset.map(rule => + lazy.ExtensionDNR.RuleValidator.deserializeRule(rule) + ); + return new StoreData(extension, extCacheData); + } + + /** + * Reads the store file for the given extensions and all rules + * for the enabled static ruleset ids listed in the store file. + * + * @param {Extension} extension + * + * @returns {Promise} + */ + async #readStoreData(extension) { + // TODO(Bug 1803363): record into Glean telemetry DNR RulesetsStore store load time. + let file = this.#getStoreFilePath(extension.uuid); + let data; + let isCorrupted = false; + let storeFileFound = false; + try { + data = await IOUtils.readJSON(file, { decompress: true }); + storeFileFound = true; + } catch (e) { + if (!(DOMException.isInstance(e) && e.name === "NotFoundError")) { + Cu.reportError(e); + isCorrupted = true; + storeFileFound = true; + } + // TODO(Bug 1803363) record store read errors in telemetry scalar. + } + + // Reset data read from disk if its type isn't the expected one. + isCorrupted ||= + !data || + !Array.isArray(data.staticRulesets) || + // DNR data stored in 109 would not have any dynamicRuleset + // property and so don't consider the data corrupted if + // there isn't any dynamicRuleset property at all. + ("dynamicRuleset" in data && !Array.isArray(data.dynamicRuleset)); + + if (isCorrupted && storeFileFound) { + // Wipe the corrupted data and backup the corrupted file. + data = null; + try { + let uniquePath = await IOUtils.createUniqueFile( + PathUtils.parent(file), + PathUtils.filename(file) + ".corrupt", + 0o600 + ); + Cu.reportError( + `Detected corrupted DNR store data for ${extension.id}, renaming store data file to ${uniquePath}` + ); + await IOUtils.move(file, uniquePath); + } catch (err) { + Cu.reportError(err); + } + } + + if (!data) { + return null; + } + + const resetStaticRulesets = + // Reset the static rulesets on install or updating the extension. + // + // NOTE: this method is called only once and its return value cached in + // memory for the entire lifetime of the extension and so we don't need + // to store any flag to avoid resetting the static rulesets more than + // once for the same Extension instance. + this.#hasInstallOrUpdateStartupReason(extension) || + // Ignore the stored enabled ruleset ids if the current extension version + // mismatches the version the store data was generated from. + data.extVersion !== extension.version; + + if (resetStaticRulesets) { + data.staticRulesets = undefined; + data.extVersion = extension.version; + } + + // If the data is being loaded for a new addon install, make sure to clear + // any potential stale dynamic rules stored on disk. + // + // NOTE: this is expected to only be hit if there was a failure to cleanup + // state data upon uninstall (e.g. in case the machine shutdowns or + // Firefox crashes before we got to update the data stored on disk). + if (extension.startupReason === "ADDON_INSTALL") { + data.dynamicRuleset = []; + } + + // In the JSON stored data we only store the enabled rulestore_id and + // the actual rules have to be loaded. + data.staticRulesets = await this.#getManifestStaticRulesets( + extension, + // Only load the rules from rulesets that are enabled in the stored DNR data, + // if the array (eventually empty) of the enabled static rules isn't in the + // stored data, then load all the ones enabled in the manifest. + { enabledRulesetIds: data.staticRulesets } + ); + + if (data.dynamicRuleset?.length) { + // Make sure all dynamic rules loaded from disk as validated and normalized + // (in case they may have been tempered, but also for when we are loading + // data stored by a different Firefox version from the one that stored the + // data on disk, e.g. in case validation or normalization logic may have been + // different in the two Firefox version). + const validatedDynamicRules = this.#getValidatedRules( + extension, + "_dynamic" /* rulesetId */, + data.dynamicRuleset + ); + + let ruleQuotaCounter = new lazy.ExtensionDNR.RuleQuotaCounter(); + try { + ruleQuotaCounter.tryAddRules("_dynamic", validatedDynamicRules); + data.dynamicRuleset = validatedDynamicRules; + } catch (e) { + // This should not happen in practice, because updateDynamicRules + // rejects quota errors. If we get here, the data on disk may have been + // tampered with, or the limit was lowered in a browser update. + Cu.reportError( + `Ignoring dynamic ruleset in extension "${extension.id}" because: ${e.message}` + ); + data.dynamicRuleset = []; + } + } + // We use StoreData.fromJSON here to prevent properties that are not expected to + // be stored in the JSON file from overriding other StoreData constructor properties + // that are not included in the JSON data returned by StoreData toJSON. + return StoreData.fromJSON(data, extension); + } + + async scheduleCacheDataSave() { + this.#ensureCacheDirectory(); + if (!this._saveCacheTask) { + this._saveCacheTask = new lazy.DeferredTask( + () => this.#saveCacheDataNow(), + 5000 + ); + IOUtils.profileBeforeChange.addBlocker( + "Flush WebExtensions DNR RulesetsStore startupCache", + async () => { + await this._saveCacheTask.finalize(); + this._saveCacheTask = null; + } + ); + } + + return this._saveCacheTask.arm(); + } + + getStartupCacheData() { + const filteredData = new Map(); + const seenLastUpdateTags = new Set(); + for (const [extUUID, dataEntry] of this._data) { + // Only store in the startup cache extensions that are permanently + // installed (the temporarilyInstalled extension are removed + // automatically either on shutdown or startup, and so the data + // stored and then loaded back from the startup cache file + // would never be used). + if (dataEntry.isFromTemporarilyInstalled()) { + continue; + } + filteredData.set(extUUID, dataEntry); + seenLastUpdateTags.add(dataEntry.lastUpdateTag); + } + return { + seenLastUpdateTags, + filteredData, + }; + } + + detectStartupCacheDataChanged(seenLastUpdateTags) { + // Detect if there are changes to the stored data applied while we + // have been writing the cache data on disk, and reschedule a new + // cache data save if that is the case. + // TODO(Bug 1825510): detect also obsoleted entries to make sure + // they are removed from the startup cache data stored on disk + // sooner. + for (const dataEntry of this._data.values()) { + if (dataEntry.isFromTemporarilyInstalled()) { + continue; + } + if (!seenLastUpdateTags.has(dataEntry.lastUpdateTag)) { + return true; + } + } + return false; + } + + async #saveCacheDataNow() { + const startTime = Cu.now(); + const timerId = Glean.extensionsApisDnr.startupCacheWriteTime.start(); + try { + const cacheFilePath = this.#getCacheFilePath(); + const { filteredData, seenLastUpdateTags } = this.getStartupCacheData(); + const data = new Uint8Array( + lazy.aomStartup.encodeBlob({ + cacheData: filteredData, + }) + ); + await this._ensureCacheDirectoryPromise; + await IOUtils.write(cacheFilePath, data, { + tmpPath: `${cacheFilePath}.tmp`, + }); + Glean.extensionsApisDnr.startupCacheWriteSize.accumulate(data.byteLength); + + if (this.detectStartupCacheDataChanged(seenLastUpdateTags)) { + this.scheduleCacheDataSave(); + } + } finally { + ChromeUtils.addProfilerMarker( + "ExtensionDNRStore", + { startTime }, + "#saveCacheDataNow" + ); + Glean.extensionsApisDnr.startupCacheWriteTime.stopAndAccumulate(timerId); + } + } + + /** + * Save the data for the given extension on disk. + * + * @param {string} extensionUUID + * @param {string} extensionId + * @returns {Promise} + */ + async #saveNow(extensionUUID, extensionId) { + const startTime = Cu.now(); + try { + if ( + !this._dataPromises.has(extensionUUID) || + !this._data.has(extensionUUID) + ) { + throw new Error( + `Unexpected uninitialized DNR store on saving data for extension uuid "${extensionUUID}"` + ); + } + const storeFile = this.#getStoreFilePath(extensionUUID); + const data = this._data.get(extensionUUID); + await this.#ensureStoreDirectory(extensionUUID); + await IOUtils.writeJSON(storeFile, data, { + tmpPath: `${storeFile}.tmp`, + compress: true, + }); + + this.scheduleCacheDataSave(); + + // TODO(Bug 1803363): report jsonData lengths into a telemetry scalar. + // TODO(Bug 1803363): report jsonData time to write into a telemetry scalar. + } catch (err) { + Cu.reportError(err); + throw err; + } finally { + this._savePromises.delete(extensionUUID); + ChromeUtils.addProfilerMarker( + "ExtensionDNRStore", + { startTime }, + `#saveNow, addonId: ${extensionId}` + ); + } + } + + /** + * Unload data for the given extension UUID from memory (e.g. when the extension is disabled or uninstalled), + * waits for a pending save promise to be settled if any. + * + * NOTE: this method clear the data cached in memory and close the update queue + * and so it should only be called from the extension shutdown handler and + * by the initExtension method before pushing into the update queue for the + * for the extension the initExtension task. + * + * @param {string} extensionUUID + * @returns {Promise} + */ + async #unloadData(extensionUUID) { + // Wait for the update tasks to have been executed, then + // wait for the data to have been saved and finally unload + // the data cached in memory. + const dataUpdateQueue = this._dataUpdateQueues.has(extensionUUID) + ? this._dataUpdateQueues.get(extensionUUID) + : undefined; + + if (dataUpdateQueue) { + try { + await dataUpdateQueue.close(); + } catch (err) { + // Unexpected error on closing the update queue. + Cu.reportError(err); + } + this._dataUpdateQueues.delete(extensionUUID); + } + + const savePromise = this._savePromises.get(extensionUUID); + if (savePromise) { + await savePromise; + this._savePromises.delete(extensionUUID); + } + + this._dataPromises.delete(extensionUUID); + this._data.delete(extensionUUID); + } + + /** + * Internal implementation for updating the dynamic ruleset and enforcing + * dynamic rules count limits. + * + * Callers ensure that there is never a concurrent call of #updateDynamicRules + * for a given extension, so we can safely modify ruleManager.dynamicRules + * from inside this method, even asynchronously. + * + * @param {Extension} extension + * @param {object} params + * @param {Array} [params.removeRuleIds=[]] + * @param {Array} [params.addRules=[]] + */ + async #updateDynamicRules(extension, { removeRuleIds, addRules }) { + const ruleManager = lazy.ExtensionDNR.getRuleManager(extension); + const ruleValidator = new lazy.ExtensionDNR.RuleValidator( + ruleManager.getDynamicRules() + ); + if (removeRuleIds) { + ruleValidator.removeRuleIds(removeRuleIds); + } + if (addRules) { + ruleValidator.addRules(addRules); + } + let failures = ruleValidator.getFailures(); + if (failures.length) { + throw new ExtensionError(failures[0].message); + } + + const validatedRules = ruleValidator.getValidatedRules(); + let ruleQuotaCounter = new lazy.ExtensionDNR.RuleQuotaCounter(); + ruleQuotaCounter.tryAddRules("_dynamic", validatedRules); + + this._data.get(extension.uuid).updateRulesets({ + dynamicRuleset: validatedRules, + }); + await this.save(extension); + // updateRulesetManager calls ruleManager.setDynamicRules using the + // validated rules assigned above to this._data. + this.updateRulesetManager(extension, { + updateDynamicRuleset: true, + updateStaticRulesets: false, + }); + } + + /** + * Internal implementation for updating the enabled rulesets and enforcing + * static rulesets and rules count limits. + * + * @param {Extension} extension + * @param {object} params + * @param {Array} [params.disableRulesetIds=[]] + * @param {Array} [params.enableRulesetIds=[]] + */ + async #updateEnabledStaticRulesets( + extension, + { disableRulesetIds, enableRulesetIds } + ) { + const ruleResources = + extension.manifest.declarative_net_request?.rule_resources; + if (!Array.isArray(ruleResources)) { + return; + } + + const enabledRulesets = await this.getEnabledStaticRulesets(extension); + const updatedEnabledRulesets = new Map(); + let disableIds = new Set(disableRulesetIds); + let enableIds = new Set(enableRulesetIds); + + // valiate the ruleset ids for existence (which will also reject calls + // including the reserved _session and _dynamic, because static rulesets + // id are validated as part of the manifest validation and they are not + // allowed to start with '_'). + const existingIds = new Set(ruleResources.map(rs => rs.id)); + const errorOnInvalidRulesetIds = rsIdSet => { + for (const rsId of rsIdSet) { + if (!existingIds.has(rsId)) { + throw new ExtensionError(`Invalid ruleset id: "${rsId}"`); + } + } + }; + errorOnInvalidRulesetIds(disableIds); + errorOnInvalidRulesetIds(enableIds); + + // Copy into the updatedEnabledRulesets Map any ruleset that is not + // requested to be disabled or is enabled back in the same request. + for (const [rulesetId, ruleset] of enabledRulesets) { + if (!disableIds.has(rulesetId) || enableIds.has(rulesetId)) { + updatedEnabledRulesets.set(rulesetId, ruleset); + enableIds.delete(rulesetId); + } + } + + const { MAX_NUMBER_OF_ENABLED_STATIC_RULESETS } = lazy.ExtensionDNRLimits; + + const maxNewRulesetsCount = + MAX_NUMBER_OF_ENABLED_STATIC_RULESETS - updatedEnabledRulesets.size; + + if (enableIds.size > maxNewRulesetsCount) { + // Log an error for the developer. + throw new ExtensionError( + `updatedEnabledRulesets request is exceeding MAX_NUMBER_OF_ENABLED_STATIC_RULESETS` + ); + } + + // At this point, every item in |updatedEnabledRulesets| is an enabled + // ruleset with already-valid rules. In order to not exceed the rule quota + // when previously-disabled rulesets are enabled, we need to count what we + // already have. + let ruleQuotaCounter = new lazy.ExtensionDNR.RuleQuotaCounter( + /* isStaticRulesets */ true + ); + for (let [rulesetId, ruleset] of updatedEnabledRulesets) { + ruleQuotaCounter.tryAddRules(rulesetId, ruleset.rules); + } + + const newRulesets = await this.#getManifestStaticRulesets(extension, { + enabledRulesetIds: Array.from(enableIds), + ruleQuotaCounter, + isUpdateEnabledRulesets: true, + }); + + for (const [rulesetId, ruleset] of newRulesets.entries()) { + updatedEnabledRulesets.set(rulesetId, ruleset); + } + + this._data.get(extension.uuid).updateRulesets({ + staticRulesets: updatedEnabledRulesets, + }); + await this.save(extension); + this.updateRulesetManager(extension, { + updateDynamicRuleset: false, + updateStaticRulesets: true, + }); + } +} + +let store = new RulesetsStore(); + +export const ExtensionDNRStore = { + async clearOnUninstall(extensionUUID) { + return store.clearOnUninstall(extensionUUID); + }, + async initExtension(extension) { + await store.initExtension(extension); + }, + async updateDynamicRules(extension, updateRuleOptions) { + await store.updateDynamicRules(extension, updateRuleOptions); + }, + async updateEnabledStaticRulesets(extension, updateRulesetOptions) { + await store.updateEnabledStaticRulesets(extension, updateRulesetOptions); + }, + // Test-only helpers + _getLastUpdateTag(extensionUUID) { + requireTestOnlyCallers(); + return StoreData.getLastUpdateTag(extensionUUID); + }, + _getStoreForTesting() { + requireTestOnlyCallers(); + return store; + }, + _getStoreDataClassForTesting() { + requireTestOnlyCallers(); + return StoreData; + }, + _recreateStoreForTesting() { + requireTestOnlyCallers(); + store = new RulesetsStore(); + return store; + }, + _storeLastUpdateTag(extensionUUID, lastUpdateTag) { + requireTestOnlyCallers(); + return StoreData.storeLastUpdateTag(extensionUUID, lastUpdateTag); + }, +}; diff --git a/toolkit/components/extensions/ExtensionPageChild.sys.mjs b/toolkit/components/extensions/ExtensionPageChild.sys.mjs new file mode 100644 index 0000000000..d84459f1ed --- /dev/null +++ b/toolkit/components/extensions/ExtensionPageChild.sys.mjs @@ -0,0 +1,510 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +/** + * This file handles privileged extension page logic that runs in the + * child process. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionChildDevToolsUtils: + "resource://gre/modules/ExtensionChildDevToolsUtils.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", +}); + +const CATEGORY_EXTENSION_SCRIPTS_ADDON = "webextension-scripts-addon"; +const CATEGORY_EXTENSION_SCRIPTS_DEVTOOLS = "webextension-scripts-devtools"; + +import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; +import { + ExtensionChild, + ExtensionActivityLogChild, +} from "resource://gre/modules/ExtensionChild.sys.mjs"; +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { getInnerWindowID, promiseEvent } = ExtensionUtils; + +const { BaseContext, CanOfAPIs, SchemaAPIManager, redefineGetter } = + ExtensionCommon; + +const { ChildAPIManager, Messenger } = ExtensionChild; + +const initializeBackgroundPage = context => { + // Override the `alert()` method inside background windows; + // we alias it to console.log(). + // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1203394 + let alertDisplayedWarning = false; + const innerWindowID = getInnerWindowID(context.contentWindow); + + /** @param {{ text, filename, lineNumber?, columnNumber? }} options */ + function logWarningMessage({ text, filename, lineNumber, columnNumber }) { + let consoleMsg = Cc["@mozilla.org/scripterror;1"].createInstance( + Ci.nsIScriptError + ); + consoleMsg.initWithWindowID( + text, + filename, + null, + lineNumber, + columnNumber, + Ci.nsIScriptError.warningFlag, + "webextension", + innerWindowID + ); + Services.console.logMessage(consoleMsg); + } + + function ignoredSuspendListener() { + logWarningMessage({ + text: "Background event page was not terminated on idle because a DevTools toolbox is attached to the extension.", + filename: context.contentWindow.location.href, + }); + } + + if (!context.extension.manifest.background.persistent) { + context.extension.on( + "background-script-suspend-ignored", + ignoredSuspendListener + ); + context.callOnClose({ + close: () => { + context.extension.off( + "background-script-suspend-ignored", + ignoredSuspendListener + ); + }, + }); + } + + let alertOverwrite = text => { + const { filename, columnNumber, lineNumber } = Components.stack.caller; + + if (!alertDisplayedWarning) { + context.childManager.callParentAsyncFunction( + "runtime.openBrowserConsole", + [] + ); + + logWarningMessage({ + text: "alert() is not supported in background windows; please use console.log instead.", + filename, + lineNumber, + columnNumber, + }); + + alertDisplayedWarning = true; + } + + logWarningMessage({ text, filename, lineNumber, columnNumber }); + }; + Cu.exportFunction(alertOverwrite, context.contentWindow, { + defineAs: "alert", + }); +}; + +var apiManager = new (class extends SchemaAPIManager { + constructor() { + super("addon", lazy.Schemas); + this.initialized = false; + } + + lazyInit() { + if (!this.initialized) { + this.initialized = true; + this.initGlobal(); + for (let { value } of Services.catMan.enumerateCategory( + CATEGORY_EXTENSION_SCRIPTS_ADDON + )) { + this.loadScript(value); + } + } + } +})(); + +var devtoolsAPIManager = new (class extends SchemaAPIManager { + constructor() { + super("devtools", lazy.Schemas); + this.initialized = false; + } + + lazyInit() { + if (!this.initialized) { + this.initialized = true; + this.initGlobal(); + for (let { value } of Services.catMan.enumerateCategory( + CATEGORY_EXTENSION_SCRIPTS_DEVTOOLS + )) { + this.loadScript(value); + } + } + } +})(); + +export function getContextChildManagerGetter( + { envType }, + ChildAPIManagerClass = ChildAPIManager +) { + return function () { + let apiManager = + envType === "devtools_parent" + ? devtoolsAPIManager + : this.extension.apiManager; + + apiManager.lazyInit(); + + let localApis = {}; + let can = new CanOfAPIs(this, apiManager, localApis); + + let childManager = new ChildAPIManagerClass( + this, + this.messageManager, + can, + { + envType, + viewType: this.viewType, + url: this.uri.spec, + incognito: this.incognito, + // Additional data a BaseContext subclass may optionally send + // as part of the CreateProxyContext request sent to the main process + // (e.g. WorkerContexChild implements this method to send the service + // worker descriptor id along with the details send by default here). + ...this.getCreateProxyContextData?.(), + } + ); + + this.callOnClose(childManager); + + return childManager; + }; +} + +export class ExtensionBaseContextChild extends BaseContext { + /** + * This ExtensionBaseContextChild represents an addon execution environment + * that is running in an addon or devtools child process. + * + * @param {BrowserExtensionContent} extension This context's owner. + * @param {object} params + * @param {string} params.envType One of "addon_child" or "devtools_child". + * @param {nsIDOMWindow} params.contentWindow The window where the addon runs. + * @param {string} params.viewType One of "background", "popup", "tab", + * "sidebar", "devtools_page" or "devtools_panel". + * @param {number} [params.tabId] This tab's ID, used if viewType is "tab". + * @param {nsIURI} [params.uri] The URI of the page. + */ + constructor(extension, params) { + if (!params.envType) { + throw new Error("Missing envType"); + } + + super(params.envType, extension); + let { viewType = "tab", uri, contentWindow, tabId } = params; + this.viewType = viewType; + this.uri = uri || extension.baseURI; + + this.setContentWindow(contentWindow); + this.browsingContextId = contentWindow.docShell.browsingContext.id; + + if (viewType == "tab") { + Object.defineProperty(this, "tabId", { + value: tabId, + enumerable: true, + configurable: true, + }); + } + + lazy.Schemas.exportLazyGetter(contentWindow, "browser", () => { + return this.browserObj; + }); + + lazy.Schemas.exportLazyGetter(contentWindow, "chrome", () => { + // For MV3 and later, this is just an alias for browser. + if (extension.manifestVersion > 2) { + return this.browserObj; + } + // Chrome compat is only used with MV2 + let chromeApiWrapper = Object.create(this.childManager); + chromeApiWrapper.isChromeCompat = true; + + let chromeObj = Cu.createObjectIn(contentWindow); + chromeApiWrapper.inject(chromeObj); + return chromeObj; + }); + } + + get browserObj() { + const browserObj = Cu.createObjectIn(this.contentWindow); + this.childManager.inject(browserObj); + return redefineGetter(this, "browserObj", browserObj); + } + + logActivity(type, name, data) { + ExtensionActivityLogChild.log(this, type, name, data); + } + + get cloneScope() { + return this.contentWindow; + } + + get principal() { + return this.contentWindow.document.nodePrincipal; + } + + get tabId() { + // Will be overwritten in the constructor if necessary. + return -1; + } + + // Called when the extension shuts down. + shutdown() { + if (this.contentWindow) { + this.contentWindow.close(); + } + + this.unload(); + } + + // This method is called when an extension page navigates away or + // its tab is closed. + unload() { + // Note that without this guard, we end up running unload code + // multiple times for tab pages closed by the "page-unload" handlers + // triggered below. + if (this.unloaded) { + return; + } + + super.unload(); + } + + get messenger() { + return redefineGetter(this, "messenger", new Messenger(this)); + } + + /** @type {ReturnType>} */ + get childManager() { + throw new Error("childManager getter must be overridden"); + } +} + +class ExtensionPageContextChild extends ExtensionBaseContextChild { + /** + * This ExtensionPageContextChild represents a privileged addon + * execution environment that has full access to the WebExtensions + * APIs (provided that the correct permissions have been requested). + * + * This is the child side of the ExtensionPageContextParent class + * defined in ExtensionParent.jsm. + * + * @param {BrowserExtensionContent} extension This context's owner. + * @param {object} params + * @param {nsIDOMWindow} params.contentWindow The window where the addon runs. + * @param {string} params.viewType One of "background", "popup", "sidebar" or "tab". + * "background", "sidebar" and "tab" are used by `browser.extension.getViews`. + * "popup" is only used internally to identify page action and browser + * action popups and options_ui pages. + * @param {number} [params.tabId] This tab's ID, used if viewType is "tab". + * @param {nsIURI} [params.uri] The URI of the page. + */ + constructor(extension, params) { + super(extension, Object.assign(params, { envType: "addon_child" })); + + if (this.viewType == "background") { + initializeBackgroundPage(this); + } + + this.extension.views.add(this); + } + + unload() { + super.unload(); + this.extension.views.delete(this); + } + + get childManager() { + const childManager = getContextChildManagerGetter({ + envType: "addon_parent", + }).call(this); + return redefineGetter(this, "childManager", childManager); + } +} + +export class DevToolsContextChild extends ExtensionBaseContextChild { + /** + * This DevToolsContextChild represents a devtools-related addon execution + * environment that has access to the devtools API namespace and to the same subset + * of APIs available in a content script execution environment. + * + * @param {BrowserExtensionContent} extension This context's owner. + * @param {object} params + * @param {nsIDOMWindow} params.contentWindow The window where the addon runs. + * @param {string} params.viewType One of "devtools_page" or "devtools_panel". + * @param {object} [params.devtoolsToolboxInfo] This devtools toolbox's information, + * used if viewType is "devtools_page" or "devtools_panel". + * @param {number} [params.tabId] This tab's ID, used if viewType is "tab". + * @param {nsIURI} [params.uri] The URI of the page. + */ + constructor(extension, params) { + super(extension, Object.assign(params, { envType: "devtools_child" })); + + this.devtoolsToolboxInfo = params.devtoolsToolboxInfo; + lazy.ExtensionChildDevToolsUtils.initThemeChangeObserver( + params.devtoolsToolboxInfo.themeName, + this + ); + + this.extension.devtoolsViews.add(this); + } + + unload() { + super.unload(); + this.extension.devtoolsViews.delete(this); + } + + get childManager() { + const childManager = getContextChildManagerGetter({ + envType: "devtools_parent", + }).call(this); + return redefineGetter(this, "childManager", childManager); + } +} + +export var ExtensionPageChild = { + initialized: false, + + // Map + extensionContexts: new Map(), + + apiManager, + + _init() { + if (this.initialized) { + return; + } + this.initialized = true; + + Services.obs.addObserver(this, "inner-window-destroyed"); // eslint-ignore-line mozilla/balanced-listeners + }, + + observe(subject, topic, data) { + if (topic === "inner-window-destroyed") { + let windowId = subject.QueryInterface(Ci.nsISupportsPRUint64).data; + + this.destroyExtensionContext(windowId); + } + }, + + expectViewLoad(global, viewType) { + promiseEvent( + global, + "DOMContentLoaded", + true, + /** @param {{target: Window|any}} event */ + event => + event.target.location != "about:blank" && + // Ignore DOMContentLoaded bubbled from child frames: + event.target.defaultView === global.content + ).then(() => { + let windowId = getInnerWindowID(global.content); + let context = this.extensionContexts.get(windowId); + // This initializes ChildAPIManager (and creation of ProxyContextParent) + // if they don't exist already at this point. + let childId = context?.childManager.id; + if (viewType === "background") { + global.sendAsyncMessage("Extension:BackgroundViewLoaded", { childId }); + } + }); + }, + + /** + * Create a privileged context at initial-document-element-inserted. + * + * @param {BrowserExtensionContent} extension + * The extension for which the context should be created. + * @param {nsIDOMWindow} contentWindow The global of the page. + */ + initExtensionContext(extension, contentWindow) { + this._init(); + + if (!WebExtensionPolicy.isExtensionProcess) { + throw new Error( + "Cannot create an extension page context in current process" + ); + } + + let windowId = getInnerWindowID(contentWindow); + let context = this.extensionContexts.get(windowId); + if (context) { + if (context.extension !== extension) { + throw new Error( + "A different extension context already exists for this frame" + ); + } + throw new Error( + "An extension context was already initialized for this frame" + ); + } + + let uri = contentWindow.document.documentURIObject; + + let mm = contentWindow.docShell.messageManager; + let data = mm.sendSyncMessage("Extension:GetFrameData")[0]; + if (!data) { + let policy = WebExtensionPolicy.getByHostname(uri.host); + // TODO bug 1749116: Handle this unexpected result, because data + // (viewType in particular) should never be void for extension documents. + Cu.reportError(`FrameData missing for ${policy?.id} page ${uri.spec}`); + } + let { viewType, tabId, devtoolsToolboxInfo } = data ?? {}; + + if (viewType && contentWindow.top === contentWindow) { + ExtensionPageChild.expectViewLoad(mm, viewType); + } + + if (devtoolsToolboxInfo) { + context = new DevToolsContextChild(extension, { + viewType, + contentWindow, + uri, + tabId, + devtoolsToolboxInfo, + }); + } else { + context = new ExtensionPageContextChild(extension, { + viewType, + contentWindow, + uri, + tabId, + }); + } + + this.extensionContexts.set(windowId, context); + }, + + /** + * Close the ExtensionPageContextChild belonging to the given window, if any. + * + * @param {number} windowId The inner window ID of the destroyed context. + */ + destroyExtensionContext(windowId) { + let context = this.extensionContexts.get(windowId); + if (context) { + context.unload(); + this.extensionContexts.delete(windowId); + } + }, + + shutdownExtension(extensionId) { + for (let [windowId, context] of this.extensionContexts) { + if (context.extension.id == extensionId) { + context.shutdown(); + this.extensionContexts.delete(windowId); + } + } + }, +}; diff --git a/toolkit/components/extensions/ExtensionParent.sys.mjs b/toolkit/components/extensions/ExtensionParent.sys.mjs new file mode 100644 index 0000000000..22ba021e15 --- /dev/null +++ b/toolkit/components/extensions/ExtensionParent.sys.mjs @@ -0,0 +1,2300 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +/** + * This module contains code for managing APIs that need to run in the + * parent process, and handles the parent side of operations that need + * to be proxied from ExtensionChild.jsm. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + BroadcastConduit: "resource://gre/modules/ConduitsParent.sys.mjs", + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs", + ExtensionActivityLog: "resource://gre/modules/ExtensionActivityLog.sys.mjs", + ExtensionData: "resource://gre/modules/Extension.sys.mjs", + GeckoViewConnection: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", + MessageManagerProxy: "resource://gre/modules/MessageManagerProxy.sys.mjs", + NativeApp: "resource://gre/modules/NativeMessaging.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", + getErrorNameForTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + aomStartup: [ + "@mozilla.org/addons/addon-manager-startup;1", + "amIAddonManagerStartup", + ], +}); + +import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const DUMMY_PAGE_URI = Services.io.newURI( + "chrome://extensions/content/dummy.xhtml" +); + +var { BaseContext, CanOfAPIs, SchemaAPIManager, SpreadArgs, redefineGetter } = + ExtensionCommon; + +var { + DefaultMap, + DefaultWeakMap, + ExtensionError, + promiseDocumentLoaded, + promiseEvent, + promiseObserved, +} = ExtensionUtils; + +const ERROR_NO_RECEIVERS = + "Could not establish connection. Receiving end does not exist."; + +const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json"; +const CATEGORY_EXTENSION_MODULES = "webextension-modules"; +const CATEGORY_EXTENSION_SCHEMAS = "webextension-schemas"; +const CATEGORY_EXTENSION_SCRIPTS = "webextension-scripts"; + +let schemaURLs = new Set(); + +schemaURLs.add("chrome://extensions/content/schemas/experiments.json"); + +let GlobalManager; +let ParentAPIManager; + +function verifyActorForContext(actor, context) { + if (JSWindowActorParent.isInstance(actor)) { + let target = actor.browsingContext.top.embedderElement; + if (context.parentMessageManager !== target.messageManager) { + throw new Error("Got message on unexpected message manager"); + } + } else if (JSProcessActorParent.isInstance(actor)) { + if (actor.manager.remoteType !== context.extension.remoteType) { + throw new Error("Got message from unexpected process"); + } + } +} + +// This object loads the ext-*.js scripts that define the extension API. +let apiManager = new (class extends SchemaAPIManager { + constructor() { + super("main", lazy.Schemas); + this.initialized = null; + + /* eslint-disable mozilla/balanced-listeners */ + this.on("startup", (e, extension) => { + return extension.apiManager.onStartup(extension); + }); + + this.on("update", async (e, { id, resourceURI, isPrivileged }) => { + let modules = this.eventModules.get("update"); + if (modules.size == 0) { + return; + } + + let extension = new lazy.ExtensionData(resourceURI, isPrivileged); + await extension.loadManifest(); + + return Promise.all( + Array.from(modules).map(async apiName => { + let module = await this.asyncLoadModule(apiName); + module.onUpdate(id, extension.manifest); + }) + ); + }); + + this.on("uninstall", (e, { id }) => { + let modules = this.eventModules.get("uninstall"); + return Promise.all( + Array.from(modules).map(async apiName => { + let module = await this.asyncLoadModule(apiName); + return module.onUninstall(id); + }) + ); + }); + /* eslint-enable mozilla/balanced-listeners */ + + // Handle any changes that happened during startup + let disabledIds = lazy.AddonManager.getStartupChanges( + lazy.AddonManager.STARTUP_CHANGE_DISABLED + ); + if (disabledIds.length) { + this._callHandlers(disabledIds, "disable", "onDisable"); + } + + let uninstalledIds = lazy.AddonManager.getStartupChanges( + lazy.AddonManager.STARTUP_CHANGE_UNINSTALLED + ); + if (uninstalledIds.length) { + this._callHandlers(uninstalledIds, "uninstall", "onUninstall"); + } + } + + getModuleJSONURLs() { + return Array.from( + Services.catMan.enumerateCategory(CATEGORY_EXTENSION_MODULES), + ({ value }) => value + ); + } + + // Loads all the ext-*.js scripts currently registered. + lazyInit() { + if (this.initialized) { + return this.initialized; + } + + let modulesPromise = StartupCache.other.get(["parentModules"], () => + this.loadModuleJSON(this.getModuleJSONURLs()) + ); + + let scriptURLs = []; + for (let { value } of Services.catMan.enumerateCategory( + CATEGORY_EXTENSION_SCRIPTS + )) { + scriptURLs.push(value); + } + + let promise = (async () => { + let scripts = await Promise.all( + scriptURLs.map(url => ChromeUtils.compileScript(url)) + ); + + this.initModuleData(await modulesPromise); + + this.initGlobal(); + for (let script of scripts) { + script.executeInGlobal(this.global); + } + + // Load order matters here. The base manifest defines types which are + // extended by other schemas, so needs to be loaded first. + return lazy.Schemas.load(BASE_SCHEMA).then(() => { + let promises = []; + for (let { value } of Services.catMan.enumerateCategory( + CATEGORY_EXTENSION_SCHEMAS + )) { + promises.push(lazy.Schemas.load(value)); + } + for (let [url, { content }] of this.schemaURLs) { + promises.push(lazy.Schemas.load(url, content)); + } + for (let url of schemaURLs) { + promises.push(lazy.Schemas.load(url)); + } + return Promise.all(promises).then(() => { + lazy.Schemas.updateSharedSchemas(); + }); + }); + })(); + + Services.mm.addMessageListener("Extension:GetFrameData", this); + + this.initialized = promise; + return this.initialized; + } + + receiveMessage({ target }) { + let data = GlobalManager.frameData.get(target) || {}; + Object.assign(data, this.global.tabTracker.getBrowserData(target)); + return data; + } + + // Call static handlers for the given event on the given extension ids, + // and set up a shutdown blocker to ensure they all complete. + _callHandlers(ids, event, method) { + let promises = Array.from(this.eventModules.get(event)) + .map(async modName => { + let module = await this.asyncLoadModule(modName); + return ids.map(id => module[method](id)); + }) + .flat(); + if (event === "disable") { + promises.push(...ids.map(id => this.emit("disable", id))); + } + if (event === "enabling") { + promises.push(...ids.map(id => this.emit("enabling", id))); + } + + lazy.AsyncShutdown.profileBeforeChange.addBlocker( + `Extension API ${event} handlers for ${ids.join(",")}`, + Promise.all(promises) + ); + } +})(); + +/** + * @typedef {object} ParentPort + * @property {boolean} [native] + * @property {string} [senderChildId] + * @property {function(StructuredCloneHolder): any} onPortMessage + * @property {Function} onPortDisconnect + */ + +// Receives messages related to the extension messaging API and forwards them +// to relevant child messengers. Also handles Native messaging and GeckoView. +/** @typedef {typeof ProxyMessenger} NativeMessenger */ +const ProxyMessenger = { + /** @type {Map&Promise>} */ + ports: new Map(), + + init() { + this.conduit = new lazy.BroadcastConduit(ProxyMessenger, { + id: "ProxyMessenger", + reportOnClosed: "portId", + recv: ["PortConnect", "PortMessage", "NativeMessage", "RuntimeMessage"], + cast: ["PortConnect", "PortMessage", "PortDisconnect", "RuntimeMessage"], + }); + }, + + openNative(nativeApp, sender) { + let context = ParentAPIManager.getContextById(sender.childId); + if (context.extension.hasPermission("geckoViewAddons")) { + return new lazy.GeckoViewConnection( + this.getSender(context.extension, sender), + sender.actor.browsingContext.top.embedderElement, + nativeApp, + context.extension.hasPermission("nativeMessagingFromContent") + ); + } else if (sender.verified) { + return new lazy.NativeApp(context, nativeApp); + } + sender = this.getSender(context.extension, sender); + throw new Error(`Native messaging not allowed: ${JSON.stringify(sender)}`); + }, + + recvNativeMessage({ nativeApp, holder }, { sender }) { + const app = this.openNative(nativeApp, sender); + + // Track in-flight NativeApp sendMessage requests as + // a NativeApp port destroyed when the request + // has been handled. + const promiseSendMessage = app.sendMessage(holder); + const sendMessagePort = { + native: true, + senderChildId: sender.childId, + }; + this.trackNativeAppPort(sendMessagePort); + const untrackSendMessage = () => this.untrackNativeAppPort(sendMessagePort); + promiseSendMessage.then(untrackSendMessage, untrackSendMessage); + + return promiseSendMessage; + }, + + getSender(extension, source) { + let sender = { + contextId: source.id, + id: source.extensionId, + envType: source.envType, + url: source.url, + }; + + if (JSWindowActorParent.isInstance(source.actor)) { + let browser = source.actor.browsingContext.top.embedderElement; + let data = + browser && apiManager.global.tabTracker.getBrowserData(browser); + if (data?.tabId > 0) { + sender.tab = extension.tabManager.get(data.tabId, null)?.convert(); + // frameId is documented to only be set if sender.tab is set. + sender.frameId = source.frameId; + } + } + + return sender; + }, + + getTopBrowsingContextId(tabId) { + // If a tab alredy has content scripts, no need to check private browsing. + let tab = apiManager.global.tabTracker.getTab(tabId, null); + if (!tab || (tab.browser || tab).getAttribute("pending") === "true") { + // No receivers in discarded tabs, so bail early to keep the browser lazy. + throw new ExtensionError(ERROR_NO_RECEIVERS); + } + let browser = tab.linkedBrowser || tab.browser; + return browser.browsingContext.id; + }, + + // TODO: Rework/simplify this and getSender/getTopBC after bug 1580766. + async normalizeArgs(arg, sender) { + arg.extensionId = arg.extensionId || sender.extensionId; + let extension = GlobalManager.extensionMap.get(arg.extensionId); + if (!extension) { + return Promise.reject({ message: ERROR_NO_RECEIVERS }); + } + // TODO bug 1852317: This should not be unconditional. + await extension.wakeupBackground?.(); + + arg.sender = this.getSender(extension, sender); + arg.topBC = arg.tabId && this.getTopBrowsingContextId(arg.tabId); + return arg.tabId ? "tab" : "messenger"; + }, + + async recvRuntimeMessage(arg, { sender }) { + arg.firstResponse = true; + let kind = await this.normalizeArgs(arg, sender); + let result = await this.conduit.castRuntimeMessage(kind, arg); + if (!result) { + // "throw new ExtensionError" cannot be used because then the stack of the + // sendMessage call would not be added to the error object generated by + // context.normalizeError. Test coverage by test_ext_error_location.js. + return Promise.reject({ message: ERROR_NO_RECEIVERS }); + } + return result.value; + }, + + async recvPortConnect(arg, { sender }) { + if (arg.native) { + let port = this.openNative(arg.name, sender).onConnect(arg.portId, this); + port.senderChildId = sender.childId; + port.native = true; + this.ports.set(arg.portId, port); + this.trackNativeAppPort(port); + return; + } + + // PortMessages that follow will need to wait for the port to be opened. + /** @type {callback} */ + let resolvePort; + this.ports.set(arg.portId, new Promise(res => (resolvePort = res))); + + let kind = await this.normalizeArgs(arg, sender); + let all = await this.conduit.castPortConnect(kind, arg); + resolvePort(); + + // If there are no active onConnect listeners. + if (!all.some(x => x.value)) { + throw new ExtensionError(ERROR_NO_RECEIVERS); + } + }, + + async recvPortMessage({ holder }, { sender }) { + if (sender.native) { + // If the nativeApp port connect fails (e.g. if triggered by a content + // script), the portId may not be in the map (because it did throw in + // the openNative method). + return this.ports.get(sender.portId)?.onPortMessage(holder); + } + // NOTE: the following await make sure we await for promised ports + // (ports that were not yet open when added to the Map, + // see recvPortConnect). + await this.ports.get(sender.portId); + this.sendPortMessage(sender.portId, holder, !sender.source); + }, + + recvConduitClosed(sender) { + let app = this.ports.get(sender.portId); + if (this.ports.delete(sender.portId) && sender.native) { + this.untrackNativeAppPort(app); + return app.onPortDisconnect(); + } + this.sendPortDisconnect(sender.portId, null, !sender.source); + }, + + sendPortMessage(portId, holder, source = true) { + this.conduit.castPortMessage("port", { portId, source, holder }); + }, + + sendPortDisconnect(portId, error, source = true) { + let port = this.ports.get(portId); + this.untrackNativeAppPort(port); + this.conduit.castPortDisconnect("port", { portId, source, error }); + this.ports.delete(portId); + }, + + trackNativeAppPort(port) { + if (!port?.native) { + return; + } + + try { + let context = ParentAPIManager.getContextById(port.senderChildId); + context?.trackNativeAppPort(port); + } catch { + // getContextById will throw if the context has been destroyed + // in the meantime. + } + }, + + untrackNativeAppPort(port) { + if (!port?.native) { + return; + } + + try { + let context = ParentAPIManager.getContextById(port.senderChildId); + context?.untrackNativeAppPort(port); + } catch { + // getContextById will throw if the context has been destroyed + // in the meantime. + } + }, +}; +ProxyMessenger.init(); + +// Responsible for loading extension APIs into the right globals. +GlobalManager = { + // Map[extension ID -> Extension]. Determines which extension is + // responsible for content under a particular extension ID. + extensionMap: new Map(), + initialized: false, + + /** @type {WeakMap} Extension Context init data. */ + frameData: new WeakMap(), + + init(extension) { + if (this.extensionMap.size == 0) { + apiManager.on("extension-browser-inserted", this._onExtensionBrowser); + this.initialized = true; + } + this.extensionMap.set(extension.id, extension); + }, + + uninit(extension) { + this.extensionMap.delete(extension.id); + + if (this.extensionMap.size == 0 && this.initialized) { + apiManager.off("extension-browser-inserted", this._onExtensionBrowser); + this.initialized = false; + } + }, + + _onExtensionBrowser(type, browser, data = {}) { + data.viewType = browser.getAttribute("webextension-view-type"); + if (data.viewType) { + GlobalManager.frameData.set(browser, data); + } + }, + + getExtension(extensionId) { + return this.extensionMap.get(extensionId); + }, +}; + +/** + * The proxied parent side of a context in ExtensionChild.jsm, for the + * parent side of a proxied API. + */ +class ProxyContextParent extends BaseContext { + constructor(envType, extension, params, browsingContext, principal) { + super(envType, extension); + + this.childId = params.childId; + this.uri = Services.io.newURI(params.url); + + this.incognito = params.incognito; + + this.listenerPromises = new Set(); + + // browsingContext is null when subclassed by BackgroundWorkerContextParent. + const xulBrowser = browsingContext?.top.embedderElement; + // This message manager is used by ParentAPIManager to send messages and to + // close the ProxyContext if the underlying message manager closes. This + // message manager object may change when `xulBrowser` swaps docshells, e.g. + // when a tab is moved to a different window. + // TODO: Is xulBrowser correct for ContentScriptContextParent? Messages + // through the xulBrowser won't reach cross-process iframes. + this.messageManagerProxy = + xulBrowser && new lazy.MessageManagerProxy(xulBrowser); + + Object.defineProperty(this, "principal", { + value: principal, + enumerable: true, + configurable: true, + }); + + this.listenerProxies = new Map(); + + this.pendingEventBrowser = null; + this.callContextData = null; + + // Set of active NativeApp ports. + this.activeNativePorts = new WeakSet(); + + // Set of pending queryRunListener promises. + this.runListenerPromises = new Set(); + + apiManager.emit("proxy-context-load", this); + } + + get isProxyContextParent() { + return true; + } + + trackRunListenerPromise(runListenerPromise) { + if ( + // The extension was already shutdown. + !this.extension || + // Not a non persistent background script context. + !this.isBackgroundContext || + this.extension.persistentBackground + ) { + return; + } + const clearFromSet = () => + this.runListenerPromises.delete(runListenerPromise); + runListenerPromise.then(clearFromSet, clearFromSet); + this.runListenerPromises.add(runListenerPromise); + } + + clearPendingRunListenerPromises() { + this.runListenerPromises.clear(); + } + + get pendingRunListenerPromisesCount() { + return this.runListenerPromises.size; + } + + trackNativeAppPort(port) { + if ( + // Not a native port. + !port?.native || + // Not a non persistent background script context. + !this.isBackgroundContext || + this.extension?.persistentBackground || + // The extension was already shutdown. + !this.extension + ) { + return; + } + this.activeNativePorts.add(port); + } + + untrackNativeAppPort(port) { + this.activeNativePorts.delete(port); + } + + get hasActiveNativeAppPorts() { + return !!ChromeUtils.nondeterministicGetWeakSetKeys(this.activeNativePorts) + .length; + } + + /** + * Call the `callable` parameter with `context.callContextData` set to the value passed + * as the first parameter of this method. + * + * `context.callContextData` is expected to: + * - don't be set when context.withCallContextData is being called + * - be set back to null right after calling the `callable` function, without + * awaiting on any async code that the function may be running internally + * + * The callable method itself is responsabile of eventually retrieve the value initially set + * on the `context.callContextData` before any code executed asynchronously (e.g. from a + * callback or after awaiting internally on a promise if the `callable` function was async). + * + * @param {object} callContextData + * @param {boolean} callContextData.isHandlingUserInput + * @param {Function} callable + * + * @returns {any} Returns the value returned by calling the `callable` method. + */ + withCallContextData({ isHandlingUserInput }, callable) { + if (this.callContextData) { + Cu.reportError( + `Unexpected pre-existing callContextData on "${this.extension?.policy.debugName}" contextId ${this.contextId}` + ); + } + + try { + this.callContextData = { + isHandlingUserInput, + }; + return callable(); + } finally { + this.callContextData = null; + } + } + + async withPendingBrowser(browser, callable) { + let savedBrowser = this.pendingEventBrowser; + this.pendingEventBrowser = browser; + try { + let result = await callable(); + return result; + } finally { + this.pendingEventBrowser = savedBrowser; + } + } + + logActivity(type, name, data) { + // The base class will throw so we catch any subclasses that do not implement. + // We do not want to throw here, but we also do not log here. + } + + get cloneScope() { + return this.sandbox; + } + + applySafe(callback, args) { + // There's no need to clone when calling listeners for a proxied + // context. + return this.applySafeWithoutClone(callback, args); + } + + get xulBrowser() { + return this.messageManagerProxy?.eventTarget; + } + + get parentMessageManager() { + // TODO bug 1595186: Replace use of parentMessageManager. + return this.messageManagerProxy?.messageManager; + } + + shutdown() { + this.unload(); + } + + unload() { + if (this.unloaded) { + return; + } + + this.messageManagerProxy?.dispose(); + + super.unload(); + apiManager.emit("proxy-context-unload", this); + } + + get apiCan() { + const apiCan = new CanOfAPIs(this, this.extension.apiManager, {}); + return redefineGetter(this, "apiCan", apiCan); + } + + get apiObj() { + return redefineGetter(this, "apiObj", this.apiCan.root); + } + + get sandbox() { + // Note: Blob and URL globals are used in ext-contentScripts.js. + const sandbox = Cu.Sandbox(this.principal, { + sandboxName: this.uri.spec, + wantGlobalProperties: ["Blob", "URL"], + }); + return redefineGetter(this, "sandbox", sandbox); + } +} + +/** + * The parent side of proxied API context for extension content script + * running in ExtensionContent.jsm. + */ +class ContentScriptContextParent extends ProxyContextParent {} + +/** + * The parent side of proxied API context for extension page, such as a + * background script, a tab page, or a popup, running in + * ExtensionChild.jsm. + */ +class ExtensionPageContextParent extends ProxyContextParent { + constructor(envType, extension, params, browsingContext) { + super(envType, extension, params, browsingContext, extension.principal); + + this.viewType = params.viewType; + this.isTopContext = browsingContext.top === browsingContext; + + this.extension.views.add(this); + + extension.emit("extension-proxy-context-load", this); + } + + // The window that contains this context. This may change due to moving tabs. + get appWindow() { + let win = this.xulBrowser.ownerGlobal; + return win.browsingContext.topChromeWindow; + } + + get currentWindow() { + if (this.viewType !== "background") { + return this.appWindow; + } + } + + get tabId() { + let { tabTracker } = apiManager.global; + let data = tabTracker.getBrowserData(this.xulBrowser); + if (data.tabId >= 0) { + return data.tabId; + } + } + + unload() { + super.unload(); + this.extension.views.delete(this); + } + + shutdown() { + apiManager.emit("page-shutdown", this); + super.shutdown(); + } +} + +/** + * The parent side of proxied API context for devtools extension page, such as a + * devtools pages and panels running in ExtensionChild.jsm. + */ +class DevToolsExtensionPageContextParent extends ExtensionPageContextParent { + constructor(...params) { + super(...params); + + // Set all attributes that are lazily defined to `null` here. + // + // Note that we can't do that for `this._devToolsToolbox` because it will + // be defined when calling our parent constructor and so would override it back to `null`. + this._devToolsCommands = null; + this._onNavigatedListeners = null; + + this._onResourceAvailable = this._onResourceAvailable.bind(this); + } + + set devToolsToolbox(toolbox) { + if (this._devToolsToolbox) { + throw new Error("Cannot set the context DevTools toolbox twice"); + } + + this._devToolsToolbox = toolbox; + } + + get devToolsToolbox() { + return this._devToolsToolbox; + } + + async addOnNavigatedListener(listener) { + if (!this._onNavigatedListeners) { + this._onNavigatedListeners = new Set(); + + await this.devToolsToolbox.resourceCommand.watchResources( + [this.devToolsToolbox.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: this._onResourceAvailable, + ignoreExistingResources: true, + } + ); + } + + this._onNavigatedListeners.add(listener); + } + + removeOnNavigatedListener(listener) { + if (this._onNavigatedListeners) { + this._onNavigatedListeners.delete(listener); + } + } + + /** + * The returned "commands" object, exposing modules implemented from devtools/shared/commands. + * Each attribute being a static interface to communicate with the server backend. + * + * @returns {Promise} + */ + async getDevToolsCommands() { + // Ensure that we try to instantiate a commands only once, + // even if createCommandsForTabForWebExtension is async. + if (this._devToolsCommandsPromise) { + return this._devToolsCommandsPromise; + } + if (this._devToolsCommands) { + return this._devToolsCommands; + } + + this._devToolsCommandsPromise = (async () => { + const commands = + await lazy.DevToolsShim.createCommandsForTabForWebExtension( + this.devToolsToolbox.commands.descriptorFront.localTab + ); + await commands.targetCommand.startListening(); + this._devToolsCommands = commands; + this._devToolsCommandsPromise = null; + return commands; + })(); + return this._devToolsCommandsPromise; + } + + unload() { + // Bail if the toolbox reference was already cleared. + if (!this.devToolsToolbox) { + return; + } + + if (this._onNavigatedListeners) { + this.devToolsToolbox.resourceCommand.unwatchResources( + [this.devToolsToolbox.resourceCommand.TYPES.DOCUMENT_EVENT], + { onAvailable: this._onResourceAvailable } + ); + } + + if (this._devToolsCommands) { + this._devToolsCommands.destroy(); + this._devToolsCommands = null; + } + + if (this._onNavigatedListeners) { + this._onNavigatedListeners.clear(); + this._onNavigatedListeners = null; + } + + this._devToolsToolbox = null; + + super.unload(); + } + + async _onResourceAvailable(resources) { + for (const resource of resources) { + const { targetFront } = resource; + if (targetFront.isTopLevel && resource.name === "dom-complete") { + for (const listener of this._onNavigatedListeners) { + listener(targetFront.url); + } + } + } + } +} + +/** + * The parent side of proxied API context for extension background service + * worker script. + */ +class BackgroundWorkerContextParent extends ProxyContextParent { + constructor(envType, extension, params) { + // TODO: split out from ProxyContextParent a base class that + // doesn't expect a browsingContext and one for contexts that are + // expected to have a browsingContext associated. + super(envType, extension, params, null, extension.principal); + + this.viewType = params.viewType; + this.workerDescriptorId = params.workerDescriptorId; + + this.extension.views.add(this); + + extension.emit("extension-proxy-context-load", this); + } +} + +ParentAPIManager = { + proxyContexts: new Map(), + + init() { + // TODO: Bug 1595186 - remove/replace all usage of MessageManager below. + Services.obs.addObserver(this, "message-manager-close"); + + this.conduit = new lazy.BroadcastConduit(this, { + id: "ParentAPIManager", + reportOnClosed: "childId", + recv: [ + "CreateProxyContext", + "ContextLoaded", + "APICall", + "AddListener", + "RemoveListener", + ], + send: ["CallResult"], + query: ["RunListener", "StreamFilterSuspendCancel"], + }); + }, + + attachMessageManager(extension, processMessageManager) { + extension.parentMessageManager = processMessageManager; + }, + + async observe(subject, topic, data) { + if (topic === "message-manager-close") { + let mm = subject; + for (let [childId, context] of this.proxyContexts) { + if (context.parentMessageManager === mm) { + this.closeProxyContext(childId); + } + } + + // Reset extension message managers when their child processes shut down. + for (let extension of GlobalManager.extensionMap.values()) { + if (extension.parentMessageManager === mm) { + extension.parentMessageManager = null; + } + } + } + }, + + shutdownExtension(extensionId, reason) { + if (["ADDON_DISABLE", "ADDON_UNINSTALL"].includes(reason)) { + apiManager._callHandlers([extensionId], "disable", "onDisable"); + } + + for (let [childId, context] of this.proxyContexts) { + if (context.extension.id == extensionId) { + context.shutdown(); + this.proxyContexts.delete(childId); + } + } + }, + + queryStreamFilterSuspendCancel(childId) { + return this.conduit.queryStreamFilterSuspendCancel(childId); + }, + + recvCreateProxyContext(data, { actor, sender }) { + let { envType, extensionId, childId, principal } = data; + + if (this.proxyContexts.has(childId)) { + throw new Error( + "A WebExtension context with the given ID already exists!" + ); + } + + let extension = GlobalManager.getExtension(extensionId); + if (!extension) { + throw new Error(`No WebExtension found with ID ${extensionId}`); + } + + let context; + if (envType == "addon_parent" || envType == "devtools_parent") { + if (!sender.verified) { + throw new Error(`Bad sender context envType: ${sender.envType}`); + } + + let isBackgroundWorker = false; + if (JSWindowActorParent.isInstance(actor)) { + const target = actor.browsingContext.top.embedderElement; + let processMessageManager = + target.messageManager.processMessageManager || + Services.ppmm.getChildAt(0); + + if (!extension.parentMessageManager) { + if (target.remoteType === extension.remoteType) { + this.attachMessageManager(extension, processMessageManager); + } + } + + if (processMessageManager !== extension.parentMessageManager) { + throw new Error( + "Attempt to create privileged extension parent from incorrect child process" + ); + } + } else if (JSProcessActorParent.isInstance(actor)) { + if (actor.manager.remoteType !== extension.remoteType) { + throw new Error( + "Attempt to create privileged extension parent from incorrect child process" + ); + } + + if (envType !== "addon_parent") { + throw new Error( + `Unexpected envType ${envType} on an extension process actor` + ); + } + if (data.viewType !== "background_worker") { + throw new Error( + `Unexpected viewType ${data.viewType} on an extension process actor` + ); + } + isBackgroundWorker = true; + } else { + // Unreacheable: JSWindowActorParent and JSProcessActorParent are the + // only actors. + throw new Error( + "Attempt to create privileged extension parent via incorrect actor" + ); + } + + if (isBackgroundWorker) { + context = new BackgroundWorkerContextParent(envType, extension, data); + } else if (envType == "addon_parent") { + context = new ExtensionPageContextParent( + envType, + extension, + data, + actor.browsingContext + ); + } else if (envType == "devtools_parent") { + context = new DevToolsExtensionPageContextParent( + envType, + extension, + data, + actor.browsingContext + ); + } + } else if (envType == "content_parent") { + // Note: actor is always a JSWindowActorParent, with a browsingContext. + context = new ContentScriptContextParent( + envType, + extension, + data, + actor.browsingContext, + principal + ); + } else { + throw new Error(`Invalid WebExtension context envType: ${envType}`); + } + this.proxyContexts.set(childId, context); + }, + + recvContextLoaded(data, { actor, sender }) { + let context = this.getContextById(data.childId); + verifyActorForContext(actor, context); + const { extension } = context; + extension.emit("extension-proxy-context-load:completed", context); + }, + + recvConduitClosed(sender) { + this.closeProxyContext(sender.id); + }, + + closeProxyContext(childId) { + let context = this.proxyContexts.get(childId); + if (context) { + context.unload(); + this.proxyContexts.delete(childId); + } + }, + + /** + * Call the given function and also log the call as appropriate + * (i.e., with activity logging and/or profiler markers) + * + * @param {BaseContext} context The context making this call. + * @param {object} data Additional data about the call. + * @param {Function} callable The actual implementation to invoke. + */ + async callAndLog(context, data, callable) { + let { id } = context.extension; + // If we were called via callParentAsyncFunction we don't want + // to log again, check for the flag. + const { alreadyLogged } = data.options || {}; + if (!alreadyLogged) { + lazy.ExtensionActivityLog.log( + id, + context.viewType, + "api_call", + data.path, + { + args: data.args, + } + ); + } + + let start = Cu.now(); + try { + return callable(); + } finally { + ChromeUtils.addProfilerMarker( + "ExtensionParent", + { startTime: start }, + `${id}, api_call: ${data.path}` + ); + } + }, + + async recvAPICall(data, { actor }) { + let context = this.getContextById(data.childId); + let target = actor.browsingContext?.top.embedderElement; + + verifyActorForContext(actor, context); + + let reply = result => { + if (target && !context.parentMessageManager) { + Services.console.logStringMessage( + "Cannot send function call result: other side closed connection " + + `(call data: ${uneval({ path: data.path, args: data.args })})` + ); + return; + } + + this.conduit.sendCallResult(data.childId, { + childId: data.childId, + callId: data.callId, + path: data.path, + ...result, + }); + }; + + try { + if ( + context.isBackgroundContext && + !context.extension.persistentBackground + ) { + context.extension.emit("background-script-reset-idle", { + reason: "parentApiCall", + path: data.path, + }); + } + + let args = data.args; + let { isHandlingUserInput = false } = data.options || {}; + let pendingBrowser = context.pendingEventBrowser; + let fun = await context.apiCan.asyncFindAPIPath(data.path); + let result = this.callAndLog(context, data, () => { + return context.withPendingBrowser(pendingBrowser, () => + context.withCallContextData({ isHandlingUserInput }, () => + fun(...args) + ) + ); + }); + + if (data.callId) { + result = result || Promise.resolve(); + + result.then( + result => { + result = result instanceof SpreadArgs ? [...result] : [result]; + + let holder = new StructuredCloneHolder( + `ExtensionParent/${context.extension.id}/recvAPICall/${data.path}`, + null, + result + ); + + reply({ result: holder }); + }, + error => { + error = context.normalizeError(error); + reply({ + error: { message: error.message, fileName: error.fileName }, + }); + } + ); + } + } catch (e) { + if (data.callId) { + let error = context.normalizeError(e); + reply({ error: { message: error.message } }); + } else { + Cu.reportError(e); + } + } + }, + + async recvAddListener(data, { actor }) { + let context = this.getContextById(data.childId); + + verifyActorForContext(actor, context); + + let { childId, alreadyLogged = false } = data; + let handlingUserInput = false; + + let listener = async (...listenerArgs) => { + let startTime = Cu.now(); + // Extract urgentSend flag to avoid deserializing args holder later. + let urgentSend = false; + if (listenerArgs[0] && data.path.startsWith("webRequest.")) { + urgentSend = listenerArgs[0].urgentSend; + delete listenerArgs[0].urgentSend; + } + let runListenerPromise = this.conduit.queryRunListener(childId, { + childId, + handlingUserInput, + listenerId: data.listenerId, + path: data.path, + urgentSend, + get args() { + return new StructuredCloneHolder( + `ExtensionParent/${context.extension.id}/recvAddListener/${data.path}`, + null, + listenerArgs + ); + }, + }); + context.trackRunListenerPromise(runListenerPromise); + + const result = await runListenerPromise; + let rv = result && result.deserialize(globalThis); + ChromeUtils.addProfilerMarker( + "ExtensionParent", + { startTime }, + `${context.extension.id}, api_event: ${data.path}` + ); + lazy.ExtensionActivityLog.log( + context.extension.id, + context.viewType, + "api_event", + data.path, + { args: listenerArgs, result: rv } + ); + return rv; + }; + + context.listenerProxies.set(data.listenerId, listener); + + let args = data.args; + let promise = context.apiCan.asyncFindAPIPath(data.path); + + // Store pending listener additions so we can be sure they're all + // fully initialize before we consider extension startup complete. + if (context.isBackgroundContext && context.listenerPromises) { + const { listenerPromises } = context; + listenerPromises.add(promise); + let remove = () => { + listenerPromises.delete(promise); + }; + promise.then(remove, remove); + } + + let handler = await promise; + if (handler.setUserInput) { + handlingUserInput = true; + } + handler.addListener(listener, ...args); + if (!alreadyLogged) { + lazy.ExtensionActivityLog.log( + context.extension.id, + context.viewType, + "api_call", + `${data.path}.addListener`, + { args } + ); + } + }, + + async recvRemoveListener(data) { + let context = this.getContextById(data.childId); + let listener = context.listenerProxies.get(data.listenerId); + + let handler = await context.apiCan.asyncFindAPIPath(data.path); + handler.removeListener(listener); + + let { alreadyLogged = false } = data; + if (!alreadyLogged) { + lazy.ExtensionActivityLog.log( + context.extension.id, + context.viewType, + "api_call", + `${data.path}.removeListener`, + { args: [] } + ); + } + }, + + getContextById(childId) { + let context = this.proxyContexts.get(childId); + if (!context) { + throw new Error("WebExtension context not found!"); + } + return context; + }, +}; + +ParentAPIManager.init(); + +/** + * A hidden window which contains the extension pages that are not visible + * (i.e., background pages and devtools pages), and is also used by + * ExtensionDebuggingUtils to contain the browser elements used by the + * addon debugger to connect to the devtools actors running in the same + * process of the target extension (and be able to stay connected across + * the addon reloads). + */ +class HiddenXULWindow { + constructor() { + this._windowlessBrowser = null; + this.unloaded = false; + this.waitInitialized = this.initWindowlessBrowser(); + } + + shutdown() { + if (this.unloaded) { + throw new Error( + "Unable to shutdown an unloaded HiddenXULWindow instance" + ); + } + + this.unloaded = true; + + this.waitInitialized = null; + + if (!this._windowlessBrowser) { + Cu.reportError("HiddenXULWindow was shut down while it was loading."); + // initWindowlessBrowser will close windowlessBrowser when possible. + return; + } + + this._windowlessBrowser.close(); + this._windowlessBrowser = null; + } + + get chromeDocument() { + return this._windowlessBrowser.document; + } + + /** + * Private helper that create a HTMLDocument in a windowless browser. + * + * @returns {Promise} + * A promise which resolves when the windowless browser is ready. + */ + async initWindowlessBrowser() { + if (this.waitInitialized) { + throw new Error("HiddenXULWindow already initialized"); + } + + // The invisible page is currently wrapped in a XUL window to fix an issue + // with using the canvas API from a background page (See Bug 1274775). + let windowlessBrowser = Services.appShell.createWindowlessBrowser(true); + + // The windowless browser is a thin wrapper around a docShell that keeps + // its related resources alive. It implements nsIWebNavigation and + // forwards its methods to the underlying docShell. That .docShell + // needs `QueryInterface(nsIWebNavigation)` to give us access to the + // webNav methods that are already available on the windowless browser. + let chromeShell = windowlessBrowser.docShell; + chromeShell.QueryInterface(Ci.nsIWebNavigation); + + if (lazy.PrivateBrowsingUtils.permanentPrivateBrowsing) { + let attrs = chromeShell.getOriginAttributes(); + attrs.privateBrowsingId = 1; + chromeShell.setOriginAttributes(attrs); + } + + windowlessBrowser.browsingContext.useGlobalHistory = false; + chromeShell.loadURI(DUMMY_PAGE_URI, { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + + await promiseObserved( + "chrome-document-global-created", + win => win.document == chromeShell.document + ); + await promiseDocumentLoaded(windowlessBrowser.document); + if (this.unloaded) { + windowlessBrowser.close(); + return; + } + this._windowlessBrowser = windowlessBrowser; + } + + /** + * Creates the browser XUL element that will contain the WebExtension Page. + * + * @param {object} xulAttributes + * An object that contains the xul attributes to set of the newly + * created browser XUL element. + * + * @returns {Promise} + * A Promise which resolves to the newly created browser XUL element. + */ + async createBrowserElement(xulAttributes) { + if (!xulAttributes || Object.keys(xulAttributes).length === 0) { + throw new Error("missing mandatory xulAttributes parameter"); + } + + await this.waitInitialized; + + const chromeDoc = this.chromeDocument; + + const browser = chromeDoc.createXULElement("browser"); + browser.setAttribute("type", "content"); + browser.setAttribute("disableglobalhistory", "true"); + browser.setAttribute("messagemanagergroup", "webext-browsers"); + browser.setAttribute("manualactiveness", "true"); + + for (const [name, value] of Object.entries(xulAttributes)) { + if (value != null) { + browser.setAttribute(name, value); + } + } + + let awaitFrameLoader; + + if (browser.getAttribute("remote") === "true") { + awaitFrameLoader = promiseEvent(browser, "XULFrameLoaderCreated"); + } + + chromeDoc.documentElement.appendChild(browser); + + // Forcibly flush layout so that we get a pres shell soon enough, see + // bug 1274775. + browser.getBoundingClientRect(); + await awaitFrameLoader; + + // FIXME(emilio): This unconditionally active frame seems rather + // unfortunate, but matches previous behavior. + browser.docShellIsActive = true; + + return browser; + } +} + +const SharedWindow = { + _window: null, + _count: 0, + + acquire() { + if (this._window == null) { + if (this._count != 0) { + throw new Error( + `Shared window already exists with count ${this._count}` + ); + } + + this._window = new HiddenXULWindow(); + } + + this._count++; + return this._window; + }, + + release() { + if (this._count < 1) { + throw new Error(`Releasing shared window with count ${this._count}`); + } + + this._count--; + if (this._count == 0) { + this._window.shutdown(); + this._window = null; + } + }, +}; + +/** + * This is a base class used by the ext-backgroundPage and ext-devtools API implementations + * to inherits the shared boilerplate code needed to create a parent document for the hidden + * extension pages (e.g. the background page, the devtools page) in the BackgroundPage and + * DevToolsPage classes. + * + * @param {Extension} extension + * The Extension which owns the hidden extension page created (used to decide + * if the hidden extension page parent doc is going to be a windowlessBrowser or + * a visible XUL window). + * @param {string} viewType + * The viewType of the WebExtension page that is going to be loaded + * in the created browser element (e.g. "background" or "devtools_page"). + */ +class HiddenExtensionPage { + constructor(extension, viewType) { + if (!extension || !viewType) { + throw new Error("extension and viewType parameters are mandatory"); + } + + this.extension = extension; + this.viewType = viewType; + this.browser = null; + this.unloaded = false; + } + + /** + * Destroy the created parent document. + */ + shutdown() { + if (this.unloaded) { + throw new Error( + "Unable to shutdown an unloaded HiddenExtensionPage instance" + ); + } + + this.unloaded = true; + + if (this.browser) { + this._releaseBrowser(); + } + } + + _releaseBrowser() { + this.browser.remove(); + this.browser = null; + SharedWindow.release(); + } + + /** + * Creates the browser XUL element that will contain the WebExtension Page. + * + * @returns {Promise} + * A Promise which resolves to the newly created browser XUL element. + */ + async createBrowserElement() { + if (this.browser) { + throw new Error("createBrowserElement called twice"); + } + + let window = SharedWindow.acquire(); + try { + this.browser = await window.createBrowserElement({ + "webextension-view-type": this.viewType, + remote: this.extension.remote ? "true" : null, + remoteType: this.extension.remoteType, + initialBrowsingContextGroupId: this.extension.browsingContextGroupId, + }); + } catch (e) { + SharedWindow.release(); + throw e; + } + + if (this.unloaded) { + this._releaseBrowser(); + throw new Error("Extension shut down before browser element was created"); + } + + return this.browser; + } +} + +/** + * This object provides utility functions needed by the devtools actors to + * be able to connect and debug an extension (which can run in the main or in + * a child extension process). + */ +const DebugUtils = { + // A lazily created hidden XUL window, which contains the browser elements + // which are used to connect the webextension patent actor to the extension process. + hiddenXULWindow: null, + + // Map> + debugBrowserPromises: new Map(), + // DefaultWeakMap, Set> + debugActors: new DefaultWeakMap(() => new Set()), + + _extensionUpdatedWatcher: null, + watchExtensionUpdated() { + if (!this._extensionUpdatedWatcher) { + // Watch the updated extension objects. + this._extensionUpdatedWatcher = async (evt, extension) => { + const browserPromise = this.debugBrowserPromises.get(extension.id); + if (browserPromise) { + const browser = await browserPromise; + if ( + browser.isRemoteBrowser !== extension.remote && + this.debugBrowserPromises.get(extension.id) === browserPromise + ) { + // If the cached browser element is not anymore of the same + // remote type of the extension, remove it. + this.debugBrowserPromises.delete(extension.id); + browser.remove(); + } + } + }; + + apiManager.on("ready", this._extensionUpdatedWatcher); + } + }, + + unwatchExtensionUpdated() { + if (this._extensionUpdatedWatcher) { + apiManager.off("ready", this._extensionUpdatedWatcher); + delete this._extensionUpdatedWatcher; + } + }, + + getExtensionManifestWarnings(id) { + const addon = GlobalManager.extensionMap.get(id); + if (addon) { + return addon.warnings; + } + return []; + }, + + /** + * Determine if the extension does have a non-persistent background script + * (either an event page or a background service worker): + * + * Based on this the DevTools client will determine if this extension should provide + * to the extension developers a button to forcefully terminate the background + * script. + * + * @param {string} addonId + * The id of the addon + * + * @returns {void|boolean} + * - undefined => does not apply (no background script in the manifest) + * - true => the background script is persistent. + * - false => the background script is an event page or a service worker. + */ + hasPersistentBackgroundScript(addonId) { + const policy = WebExtensionPolicy.getByID(addonId); + + // The addon doesn't have any background script or we + // can't be sure yet. + if ( + policy?.extension?.type !== "extension" || + !policy?.extension?.manifest?.background + ) { + return undefined; + } + + return policy.extension.persistentBackground; + }, + + /** + * Determine if the extension background page is running. + * + * Based on this the DevTools client will show the status of the background + * script in about:debugging. + * + * @param {string} addonId + * The id of the addon + * + * @returns {void|boolean} + * - undefined => does not apply (no background script in the manifest) + * - true => the background script is running. + * - false => the background script is stopped. + */ + isBackgroundScriptRunning(addonId) { + const policy = WebExtensionPolicy.getByID(addonId); + + // The addon doesn't have any background script or we + // can't be sure yet. + if (!(this.hasPersistentBackgroundScript(addonId) === false)) { + return undefined; + } + + const views = policy?.extension?.views || []; + for (const view of views) { + if ( + view.viewType === "background" || + (view.viewType === "background_worker" && !view.unloaded) + ) { + return true; + } + } + + return false; + }, + + async terminateBackgroundScript(addonId) { + // Terminate the background if the extension does have + // a non-persistent background script (event page or background + // service worker). + if (this.hasPersistentBackgroundScript(addonId) === false) { + const policy = WebExtensionPolicy.getByID(addonId); + // When the event page is being terminated through the Devtools + // action, we should terminate it even if there are DevTools + // toolboxes attached to the extension. + return policy.extension.terminateBackground({ + ignoreDevToolsAttached: true, + }); + } + throw Error(`Unable to terminate background script for ${addonId}`); + }, + + /** + * Determine whether a devtools toolbox attached to the extension. + * + * This method is called by the background page idle timeout handler, + * to inhibit terminating the event page when idle while the extension + * developer is debugging the extension through the Addon Debugging window + * (similarly to how service workers are kept alive while the devtools are + * attached). + * + * @param {string} id + * The id of the extension. + * + * @returns {boolean} + * true when a devtools toolbox is attached to an extension with + * the given id, false otherwise. + */ + hasDevToolsAttached(id) { + return this.debugBrowserPromises.has(id); + }, + + /** + * Retrieve a XUL browser element which has been configured to be able to connect + * the devtools actor with the process where the extension is running. + * + * @param {WebExtensionParentActor} webExtensionParentActor + * The devtools actor that is retrieving the browser element. + * + * @returns {Promise} + * A promise which resolves to the configured browser XUL element. + */ + async getExtensionProcessBrowser(webExtensionParentActor) { + const extensionId = webExtensionParentActor.addonId; + const extension = GlobalManager.getExtension(extensionId); + if (!extension) { + throw new Error(`Extension not found: ${extensionId}`); + } + + const createBrowser = () => { + if (!this.hiddenXULWindow) { + this.hiddenXULWindow = new HiddenXULWindow(); + this.watchExtensionUpdated(); + } + + return this.hiddenXULWindow.createBrowserElement({ + "webextension-addon-debug-target": extensionId, + remote: extension.remote ? "true" : null, + remoteType: extension.remoteType, + initialBrowsingContextGroupId: extension.browsingContextGroupId, + }); + }; + + let browserPromise = this.debugBrowserPromises.get(extensionId); + + // Create a new promise if there is no cached one in the map. + if (!browserPromise) { + browserPromise = createBrowser(); + this.debugBrowserPromises.set(extensionId, browserPromise); + browserPromise.then(browser => { + browserPromise.browser = browser; + }); + browserPromise.catch(e => { + Cu.reportError(e); + this.debugBrowserPromises.delete(extensionId); + }); + } + + this.debugActors.get(browserPromise).add(webExtensionParentActor); + + return browserPromise; + }, + + getFrameLoader(extensionId) { + let promise = this.debugBrowserPromises.get(extensionId); + return promise && promise.browser && promise.browser.frameLoader; + }, + + /** + * Given the devtools actor that has retrieved an addon debug browser element, + * it destroys the XUL browser element, and it also destroy the hidden XUL window + * if it is not currently needed. + * + * @param {WebExtensionParentActor} webExtensionParentActor + * The devtools actor that has retrieved an addon debug browser element. + */ + async releaseExtensionProcessBrowser(webExtensionParentActor) { + const extensionId = webExtensionParentActor.addonId; + const browserPromise = this.debugBrowserPromises.get(extensionId); + + if (browserPromise) { + const actorsSet = this.debugActors.get(browserPromise); + actorsSet.delete(webExtensionParentActor); + if (actorsSet.size === 0) { + this.debugActors.delete(browserPromise); + this.debugBrowserPromises.delete(extensionId); + await browserPromise.then(browser => browser.remove()); + } + } + + if (this.debugBrowserPromises.size === 0 && this.hiddenXULWindow) { + this.hiddenXULWindow.shutdown(); + this.hiddenXULWindow = null; + this.unwatchExtensionUpdated(); + } + }, +}; + +/** + * Returns a Promise which resolves with the message data when the given message + * was received by the message manager. The promise is rejected if the message + * manager was closed before a message was received. + * + * @param {nsIMessageListenerManager} messageManager + * The message manager on which to listen for messages. + * @param {string} messageName + * The message to listen for. + * @returns {Promise<*>} + */ +function promiseMessageFromChild(messageManager, messageName) { + return new Promise((resolve, reject) => { + let unregister; + function listener(message) { + unregister(); + resolve(message.data); + } + function observer(subject, topic, data) { + if (subject === messageManager) { + unregister(); + reject( + new Error( + `Message manager was disconnected before receiving ${messageName}` + ) + ); + } + } + unregister = () => { + Services.obs.removeObserver(observer, "message-manager-close"); + messageManager.removeMessageListener(messageName, listener); + }; + messageManager.addMessageListener(messageName, listener); + Services.obs.addObserver(observer, "message-manager-close"); + }); +} + +// This should be called before browser.loadURI is invoked. +async function promiseBackgroundViewLoaded(browser) { + let { childId } = await promiseMessageFromChild( + browser.messageManager, + "Extension:BackgroundViewLoaded" + ); + if (childId) { + return ParentAPIManager.getContextById(childId); + } +} + +/** + * This helper is used to subscribe a listener (e.g. in the ext-devtools API implementation) + * to be called for every ExtensionProxyContext created for an extension page given + * its related extension, viewType and browser element (both the top level context and any context + * created for the extension urls running into its iframe descendants). + * + * @param {object} params + * @param {object} params.extension + * The Extension on which we are going to listen for the newly created ExtensionProxyContext. + * @param {string} params.viewType + * The viewType of the WebExtension page that we are watching (e.g. "background" or + * "devtools_page"). + * @param {XULElement} params.browser + * The browser element of the WebExtension page that we are watching. + * @param {Function} onExtensionProxyContextLoaded + * The callback that is called when a new context has been loaded (as `callback(context)`); + * + * @returns {Function} + * Unsubscribe the listener. + */ +function watchExtensionProxyContextLoad( + { extension, viewType, browser }, + onExtensionProxyContextLoaded +) { + if (typeof onExtensionProxyContextLoaded !== "function") { + throw new Error("Missing onExtensionProxyContextLoaded handler"); + } + + const listener = (event, context) => { + if (context.viewType == viewType && context.xulBrowser == browser) { + onExtensionProxyContextLoaded(context); + } + }; + + extension.on("extension-proxy-context-load", listener); + + return () => { + extension.off("extension-proxy-context-load", listener); + }; +} + +/** + * This helper is used to subscribe a listener (e.g. in the ext-backgroundPage) + * to be called for every ExtensionProxyContext created for an extension + * background service worker given its related extension. + * + * @param {object} params + * @param {object} params.extension + * The Extension on which we are going to listen for the newly created ExtensionProxyContext. + * @param {Function} onExtensionWorkerContextLoaded + * The callback that is called when the worker script has been fully loaded (as `callback(context)`); + * + * @returns {Function} + * Unsubscribe the listener. + */ +function watchExtensionWorkerContextLoaded( + { extension }, + onExtensionWorkerContextLoaded +) { + if (typeof onExtensionWorkerContextLoaded !== "function") { + throw new Error("Missing onExtensionWorkerContextLoaded handler"); + } + + const listener = (event, context) => { + if (context.viewType == "background_worker") { + onExtensionWorkerContextLoaded(context); + } + }; + + extension.on("extension-proxy-context-load:completed", listener); + + return () => { + extension.off("extension-proxy-context-load:completed", listener); + }; +} + +// Manages icon details for toolbar buttons in the |pageAction| and +// |browserAction| APIs. +let IconDetails = { + DEFAULT_ICON: "chrome://mozapps/skin/extensions/extensionGeneric.svg", + + // WeakMap Map Map object>>> + iconCache: new DefaultWeakMap(() => { + return new DefaultMap(() => new DefaultMap(() => new Map())); + }), + + // Normalizes the various acceptable input formats into an object + // with icon size as key and icon URL as value. + // + // If a context is specified (function is called from an extension): + // Throws an error if an invalid icon size was provided or the + // extension is not allowed to load the specified resources. + // + // If no context is specified, instead of throwing an error, this + // function simply logs a warning message. + normalize(details, extension, context = null) { + if (!details.imageData && details.path != null) { + // Pick a cache key for the icon paths. If the path is a string, + // use it directly. Otherwise, stringify the path object. + let key = details.path; + if (typeof key !== "string") { + key = uneval(key); + } + + let icons = this.iconCache + .get(extension) + .get(context && context.uri.spec) + .get(details.iconType); + + let icon = icons.get(key); + if (!icon) { + icon = this._normalize(details, extension, context); + icons.set(key, icon); + } + return icon; + } + + return this._normalize(details, extension, context); + }, + + _normalize(details, extension, context = null) { + let result = {}; + + try { + let { imageData, path, themeIcons } = details; + + if (imageData) { + if (typeof imageData == "string") { + imageData = { 19: imageData }; + } + + for (let size of Object.keys(imageData)) { + result[size] = imageData[size]; + } + } + + let baseURI = context ? context.uri : extension.baseURI; + + if (path != null) { + if (typeof path != "object") { + path = { 19: path }; + } + + for (let size of Object.keys(path)) { + let url = path[size]; + if (url) { + url = baseURI.resolve(path[size]); + + // The Chrome documentation specifies these parameters as + // relative paths. We currently accept absolute URLs as well, + // which means we need to check that the extension is allowed + // to load them. This will throw an error if it's not allowed. + this._checkURL(url, extension); + } + result[size] = url || this.DEFAULT_ICON; + } + } + + if (themeIcons) { + themeIcons.forEach(({ size, light, dark }) => { + let lightURL = baseURI.resolve(light); + let darkURL = baseURI.resolve(dark); + + this._checkURL(lightURL, extension); + this._checkURL(darkURL, extension); + + let defaultURL = result[size] || result[19]; // always fallback to default first + result[size] = { + default: defaultURL || darkURL, // Fallback to the dark url if no default is specified. + light: lightURL, + dark: darkURL, + }; + }); + } + } catch (e) { + // Function is called from extension code, delegate error. + if (context) { + throw e; + } + // If there's no context, it's because we're handling this + // as a manifest directive. Log a warning rather than + // raising an error. + extension.manifestError(`Invalid icon data: ${e}`); + } + + return result; + }, + + // Checks if the extension is allowed to load the given URL with the specified principal. + // This will throw an error if the URL is not allowed. + _checkURL(url, extension) { + if (!extension.checkLoadURL(url, { allowInheritsPrincipal: true })) { + throw new ExtensionError(`Illegal URL ${url}`); + } + }, + + // Returns the appropriate icon URL for the given icons object and the + // screen resolution of the given window. + getPreferredIcon(icons, extension = null, size = 16) { + const DEFAULT = "chrome://mozapps/skin/extensions/extensionGeneric.svg"; + + let bestSize = null; + if (icons[size]) { + bestSize = size; + } else if (icons[2 * size]) { + bestSize = 2 * size; + } else { + let sizes = Object.keys(icons) + .map(key => parseInt(key, 10)) + .sort((a, b) => a - b); + + bestSize = sizes.find(candidate => candidate > size) || sizes.pop(); + } + + if (bestSize) { + return { size: bestSize, icon: icons[bestSize] || DEFAULT }; + } + + return { size, icon: DEFAULT }; + }, + + // These URLs should already be properly escaped, but make doubly sure CSS + // string escape characters are escaped here, since they could lead to a + // sandbox break. + escapeUrl(url) { + return url.replace(/[\\\s"]/g, encodeURIComponent); + }, +}; + +class CacheStore { + constructor(storeName) { + this.storeName = storeName; + } + + async getStore(path = null) { + let data = await StartupCache.dataPromise; + + let store = data.get(this.storeName); + if (!store) { + store = new Map(); + data.set(this.storeName, store); + } + + let key = path; + if (Array.isArray(path)) { + for (let elem of path.slice(0, -1)) { + let next = store.get(elem); + if (!next) { + next = new Map(); + store.set(elem, next); + } + store = next; + } + key = path[path.length - 1]; + } + + return [store, key]; + } + + async get(path, createFunc) { + let [store, key] = await this.getStore(path); + + let result = store.get(key); + + if (result === undefined) { + result = await createFunc(path); + store.set(key, result); + StartupCache.save(); + } + + return result; + } + + async set(path, value) { + let [store, key] = await this.getStore(path); + + store.set(key, value); + StartupCache.save(); + } + + async getAll() { + let [store] = await this.getStore(); + + return new Map(store); + } + + async delete(path) { + let [store, key] = await this.getStore(path); + + if (store.delete(key)) { + StartupCache.save(); + } + } +} + +// A cache to support faster initialization of extensions at browser startup. +// All cached data is removed when the browser is updated. +// Extension-specific data is removed when the add-on is updated. +var StartupCache = { + _ensureDirectoryPromise: null, + _saveTask: null, + + _ensureDirectory() { + if (this._ensureDirectoryPromise === null) { + this._ensureDirectoryPromise = IOUtils.makeDirectory( + PathUtils.parent(this.file), + { + ignoreExisting: true, + createAncestors: true, + } + ); + } + + return this._ensureDirectoryPromise; + }, + + // When the application version changes, this file is removed by + // RemoveComponentRegistries in nsAppRunner.cpp. + file: PathUtils.join( + Services.dirsvc.get("ProfLD", Ci.nsIFile).path, + "startupCache", + "webext.sc.lz4" + ), + + async _saveNow() { + let data = new Uint8Array(lazy.aomStartup.encodeBlob(this._data)); + await this._ensureDirectoryPromise; + await IOUtils.write(this.file, data, { tmpPath: `${this.file}.tmp` }); + + Glean.extensions.startupCacheWriteBytelength.set(data.byteLength); + }, + + save() { + this._ensureDirectory(); + + if (!this._saveTask) { + this._saveTask = new lazy.DeferredTask(() => this._saveNow(), 5000); + + IOUtils.profileBeforeChange.addBlocker( + "Flush WebExtension StartupCache", + async () => { + await this._saveTask.finalize(); + this._saveTask = null; + } + ); + } + + return this._saveTask.arm(); + }, + + _data: null, + async _readData() { + let result = new Map(); + try { + Glean.extensions.startupCacheLoadTime.start(); + let { buffer } = await IOUtils.read(this.file); + + result = lazy.aomStartup.decodeBlob(buffer); + Glean.extensions.startupCacheLoadTime.stop(); + } catch (e) { + Glean.extensions.startupCacheLoadTime.cancel(); + if (!DOMException.isInstance(e) || e.name !== "NotFoundError") { + Cu.reportError(e); + } + let error = lazy.getErrorNameForTelemetry(e); + Glean.extensions.startupCacheReadErrors[error].add(1); + } + + this._data = result; + return result; + }, + + get dataPromise() { + if (!this._dataPromise) { + this._dataPromise = this._readData(); + } + return this._dataPromise; + }, + + clearAddonData(id) { + return Promise.all([ + this.general.delete(id), + this.locales.delete(id), + this.manifests.delete(id), + this.permissions.delete(id), + this.menus.delete(id), + ]).catch(e => { + // Ignore the error. It happens when we try to flush the add-on + // data after the AddonManager has flushed the entire startup cache. + }); + }, + + observe(subject, topic, data) { + if (topic === "startupcache-invalidate") { + this._data = new Map(); + this._dataPromise = Promise.resolve(this._data); + } + }, + + get(extension, path, createFunc) { + return this.general.get( + [extension.id, extension.version, ...path], + createFunc + ); + }, + + delete(extension, path) { + return this.general.delete([extension.id, extension.version, ...path]); + }, + + general: new CacheStore("general"), + locales: new CacheStore("locales"), + manifests: new CacheStore("manifests"), + other: new CacheStore("other"), + permissions: new CacheStore("permissions"), + schemas: new CacheStore("schemas"), + menus: new CacheStore("menus"), +}; + +Services.obs.addObserver(StartupCache, "startupcache-invalidate"); + +export var ExtensionParent = { + GlobalManager, + HiddenExtensionPage, + IconDetails, + ParentAPIManager, + StartupCache, + WebExtensionPolicy, + apiManager, + promiseBackgroundViewLoaded, + watchExtensionProxyContextLoad, + watchExtensionWorkerContextLoaded, + DebugUtils, +}; + +// browserPaintedPromise and browserStartupPromise are promises that +// resolve after the first browser window is painted and after browser +// windows have been restored, respectively. Alternatively, +// browserStartupPromise also resolves from the extensions-late-startup +// notification sent by Firefox Reality on desktop platforms, because it +// doesn't support SessionStore. +// _resetStartupPromises should only be called from outside this file in tests. +ExtensionParent._resetStartupPromises = () => { + ExtensionParent.browserPaintedPromise = promiseObserved( + "browser-delayed-startup-finished" + ).then(() => {}); + ExtensionParent.browserStartupPromise = Promise.race([ + promiseObserved("sessionstore-windows-restored"), + promiseObserved("extensions-late-startup"), + ]).then(() => {}); +}; +ExtensionParent._resetStartupPromises(); + +ChromeUtils.defineLazyGetter(ExtensionParent, "PlatformInfo", () => { + return Object.freeze({ + os: (function () { + let os = AppConstants.platform; + if (os == "macosx") { + os = "mac"; + } + return os; + })(), + arch: (function () { + let abi = Services.appinfo.XPCOMABI; + let [arch] = abi.split("-"); + if (arch == "x86") { + arch = "x86-32"; + } else if (arch == "x86_64") { + arch = "x86-64"; + } + return arch; + })(), + }); +}); diff --git a/toolkit/components/extensions/ExtensionPermissionMessages.sys.mjs b/toolkit/components/extensions/ExtensionPermissionMessages.sys.mjs new file mode 100644 index 0000000000..2edf3a5d7b --- /dev/null +++ b/toolkit/components/extensions/ExtensionPermissionMessages.sys.mjs @@ -0,0 +1,95 @@ +/* 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/. */ + +/** + * Localization object holding the fluent definitions of permission descriptions + * of WebExtension APIs defined in toolkit. + * + * This is exported to allow builds (e.g. Thunderbird) to extend or modify the + * object via its addResourceIds() method. + */ +export const PERMISSION_L10N = new Localization( + [ + "toolkit/global/extensions.ftl", + "toolkit/global/extensionPermissions.ftl", + "branding/brand.ftl", + ], + true +); + +/** + * List of permissions that are associated with a permission message. + * + * Keep this list in sync with: + * - The messages in `toolkit/locales/en-US/toolkit/global/extensionPermissions.ftl` + * - `permissionToTranslation` at https://github.com/mozilla-mobile/firefox-android/blob/d9c08c387917e3e53963386ad53229e69d52da6e/android-components/components/feature/addons/src/main/java/mozilla/components/feature/addons/Addon.kt#L174-L206 + * - https://extensionworkshop.com/documentation/develop/request-the-right-permissions/#advised-permissions + * - https://support.mozilla.org/en-US/kb/permission-request-messages-firefox-extensions + * + * This is exported to allow builds (e.g. Thunderbird) to extend or modify the set. + */ +export const PERMISSIONS_WITH_MESSAGE = new Set([ + "bookmarks", + "browserSettings", + "browsingData", + "clipboardRead", + "clipboardWrite", + "declarativeNetRequest", + "declarativeNetRequestFeedback", + "devtools", + "downloads", + "downloads.open", + "find", + "geolocation", + "history", + "management", + "nativeMessaging", + "notifications", + "pkcs11", + "privacy", + "proxy", + "sessions", + "tabs", + "tabHide", + "topSites", + "webNavigation", +]); + +/** + * Overrides for permission description l10n identifiers, + * which by default use the pattern `webext-perms-description-${permission}` + * where `permission` is sanitized to be a valid Fluent identifier. + * + * This is exported to allow builds (e.g. Thunderbird) to extend or modify the map. + */ +export const PERMISSION_L10N_ID_OVERRIDES = new Map(); + +/** + * Maps a permission name to its l10n identifier. + * + * Returns `null` for permissions not in `PERMISSIONS_WITH_MESSAGE`. + * + * The default `webext-perms-description-${permission}` mapping + * may be overridden by entries in `PERMISSION_L10N_ID_OVERRIDES`. + * + * @param {string} permission + * @returns {string | null} + */ +export function permissionToL10nId(permission) { + if (!PERMISSIONS_WITH_MESSAGE.has(permission)) { + return null; + } + + if (PERMISSION_L10N_ID_OVERRIDES.has(permission)) { + return PERMISSION_L10N_ID_OVERRIDES.get(permission); + } + + // Sanitize input to end up with a valid l10n id. + // E.g. "" to "all-urls", "downloads.open" to "downloads-open". + const sanitized = permission + .replace(/[^a-zA-Z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + + return `webext-perms-description-${sanitized}`; +} diff --git a/toolkit/components/extensions/ExtensionPermissions.sys.mjs b/toolkit/components/extensions/ExtensionPermissions.sys.mjs new file mode 100644 index 0000000000..8308a4369a --- /dev/null +++ b/toolkit/components/extensions/ExtensionPermissions.sys.mjs @@ -0,0 +1,804 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +import { computeSha1HashAsString } from "resource://gre/modules/addons/crypto-utils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs", + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + JSONFile: "resource://gre/modules/JSONFile.sys.mjs", + KeyValueService: "resource://gre/modules/kvstore.sys.mjs", +}); + +ChromeUtils.defineLazyGetter( + lazy, + "StartupCache", + () => lazy.ExtensionParent.StartupCache +); + +ChromeUtils.defineLazyGetter( + lazy, + "Management", + () => lazy.ExtensionParent.apiManager +); + +function emptyPermissions() { + return { permissions: [], origins: [] }; +} + +const DEFAULT_VALUE = JSON.stringify(emptyPermissions()); + +const KEY_PREFIX = "id-"; + +// This is the old preference file pre-migration to rkv. +const OLD_JSON_FILENAME = "extension-preferences.json"; +// This is the old path to the rkv store dir (which used to be shared with ExtensionScriptingStore). +const OLD_RKV_DIRNAME = "extension-store"; +// This is the new path to the rkv store dir. +const RKV_DIRNAME = "extension-store-permissions"; + +const VERSION_KEY = "_version"; + +const VERSION_VALUE = 1; + +// Bug 1646182: remove once we fully migrate to rkv +let prefs; + +// Bug 1646182: remove once we fully migrate to rkv +class LegacyPermissionStore { + async lazyInit() { + if (!this._initPromise) { + this._initPromise = this._init(); + } + return this._initPromise; + } + + async _init() { + let path = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + OLD_JSON_FILENAME + ); + + prefs = new lazy.JSONFile({ path }); + prefs.data = {}; + + try { + prefs.data = await IOUtils.readJSON(path); + } catch (e) { + if (!(DOMException.isInstance(e) && e.name == "NotFoundError")) { + Cu.reportError(e); + } + } + } + + async has(extensionId) { + await this.lazyInit(); + return !!prefs.data[extensionId]; + } + + async get(extensionId) { + await this.lazyInit(); + + let perms = prefs.data[extensionId]; + if (!perms) { + perms = emptyPermissions(); + } + + return perms; + } + + async put(extensionId, permissions) { + await this.lazyInit(); + prefs.data[extensionId] = permissions; + prefs.saveSoon(); + } + + async delete(extensionId) { + await this.lazyInit(); + if (prefs.data[extensionId]) { + delete prefs.data[extensionId]; + prefs.saveSoon(); + } + } + + async uninitForTest() { + if (!this._initPromise) { + return; + } + + await this._initPromise; + await prefs.finalize(); + prefs = null; + this._initPromise = null; + } + + async resetVersionForTest() { + throw new Error("Not supported"); + } +} + +class PermissionStore { + _shouldMigrateFromOldKVStorePath = AppConstants.NIGHTLY_BUILD; + + async _init() { + const storePath = lazy.FileUtils.getDir("ProfD", [RKV_DIRNAME]).path; + // Make sure the folder exists + await IOUtils.makeDirectory(storePath, { ignoreExisting: true }); + this._store = await lazy.KeyValueService.getOrCreate( + storePath, + "permissions" + ); + if (!(await this._store.has(VERSION_KEY))) { + // If _shouldMigrateFromOldKVStorePath is true (default only on Nightly channel + // where the rkv store has been enabled by default for a while), we need to check + // if we would need to import data from the old kvstore path (ProfD/extensions-store) + // first, and fallback to try to import from the JSONFile if there was no data in + // the old kvstore path. + // NOTE: _shouldMigrateFromOldKVStorePath is also explicitly set to true in unit tests + // that are meant to explicitly cover this path also when running on on non-Nightly channels. + if (this._shouldMigrateFromOldKVStorePath) { + // Try to import data from the old kvstore path (ProfD/extensions-store). + await this.maybeImportFromOldKVStorePath(); + if (!(await this._store.has(VERSION_KEY))) { + // There was no data in the old kvstore path, migrate any data + // available from the LegacyPermissionStore JSONFile if any. + await this.maybeMigrateDataFromOldJSONFile(); + } + } else { + // On non-Nightly channels, where LegacyPermissionStore was still the + // only backend ever enabled, try to import permissions data from the + // legacy JSONFile, if any data is available there. + await this.maybeMigrateDataFromOldJSONFile(); + } + } + } + + lazyInit() { + if (!this._initPromise) { + this._initPromise = this._init(); + } + return this._initPromise; + } + + validateMigratedData(json) { + let data = {}; + for (let [extensionId, permissions] of Object.entries(json)) { + // If both arrays are empty there's no need to include the value since + // it's the default + if ( + "permissions" in permissions && + "origins" in permissions && + (permissions.permissions.length || permissions.origins.length) + ) { + data[extensionId] = permissions; + } + } + return data; + } + + async maybeMigrateDataFromOldJSONFile() { + let migrationWasSuccessful = false; + let oldStore = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + OLD_JSON_FILENAME + ); + try { + await this.migrateFrom(oldStore); + migrationWasSuccessful = true; + } catch (e) { + if (!(DOMException.isInstance(e) && e.name == "NotFoundError")) { + Cu.reportError(e); + } + } + + await this._store.put(VERSION_KEY, VERSION_VALUE); + + if (migrationWasSuccessful) { + IOUtils.remove(oldStore); + } + } + + async maybeImportFromOldKVStorePath() { + try { + const oldStorePath = lazy.FileUtils.getDir("ProfD", [ + OLD_RKV_DIRNAME, + ]).path; + if (!(await IOUtils.exists(oldStorePath))) { + return; + } + const oldStore = await lazy.KeyValueService.getOrCreate( + oldStorePath, + "permissions" + ); + const enumerator = await oldStore.enumerate(); + const kvpairs = []; + while (enumerator.hasMoreElements()) { + const { key, value } = enumerator.getNext(); + kvpairs.push([key, value]); + } + + // NOTE: we don't add a VERSION_KEY entry explicitly here because + // if the database was not empty the VERSION_KEY is already set to + // 1 and will be copied into the new file as part of the pairs + // written below (along with the entries for the actual extensions + // permissions). + if (kvpairs.length) { + await this._store.writeMany(kvpairs); + } + + // NOTE: the old rkv store path used to be shared with the + // ExtensionScriptingStore, and so we are not removing the old + // rkv store dir here (that is going to be left to a separate + // migration we will be adding to ExtensionScriptingStore). + } catch (err) { + Cu.reportError(err); + } + } + + async migrateFrom(oldStore) { + // Some other migration job might have started and not completed, let's + // start from scratch + await this._store.clear(); + + let json = await IOUtils.readJSON(oldStore); + let data = this.validateMigratedData(json); + + if (data) { + let entries = Object.entries(data).map(([extensionId, permissions]) => [ + this.makeKey(extensionId), + JSON.stringify(permissions), + ]); + if (entries.length) { + await this._store.writeMany(entries); + } + } + } + + makeKey(extensionId) { + // We do this so that the extensionId field cannot clash with internal + // fields like `_version` + return KEY_PREFIX + extensionId; + } + + async has(extensionId) { + await this.lazyInit(); + return this._store.has(this.makeKey(extensionId)); + } + + async get(extensionId) { + await this.lazyInit(); + return this._store + .get(this.makeKey(extensionId), DEFAULT_VALUE) + .then(JSON.parse); + } + + async put(extensionId, permissions) { + await this.lazyInit(); + return this._store.put( + this.makeKey(extensionId), + JSON.stringify(permissions) + ); + } + + async delete(extensionId) { + await this.lazyInit(); + return this._store.delete(this.makeKey(extensionId)); + } + + async resetVersionForTest() { + await this.lazyInit(); + return this._store.delete(VERSION_KEY); + } + + async uninitForTest() { + // Nothing special to do to unitialize, let's just + // make sure we're not in the middle of initialization + return this._initPromise; + } +} + +// Bug 1646182: turn on rkv on all channels +function createStore(useRkv = AppConstants.NIGHTLY_BUILD) { + if (useRkv) { + return new PermissionStore(); + } + return new LegacyPermissionStore(); +} + +let store = createStore(); + +export var ExtensionPermissions = { + async _update(extensionId, perms) { + await store.put(extensionId, perms); + return lazy.StartupCache.permissions.set(extensionId, perms); + }, + + async _get(extensionId) { + return store.get(extensionId); + }, + + async _getCached(extensionId) { + return lazy.StartupCache.permissions.get(extensionId, () => + this._get(extensionId) + ); + }, + + /** + * Retrieves the optional permissions for the given extension. + * The information may be retrieved from the StartupCache, and otherwise fall + * back to data from the disk (and cache the result in the StartupCache). + * + * @param {string} extensionId The extensionId + * @returns {object} An object with "permissions" and "origins" array. + * The object may be a direct reference to the storage or cache, so its + * value should immediately be used and not be modified by callers. + */ + get(extensionId) { + return this._getCached(extensionId); + }, + + _fixupAllUrlsPerms(perms) { + // Unfortunately, we treat as an API permission as well. + // If it is added to either, ensure it is added to both. + if (perms.origins.includes("")) { + perms.permissions.push(""); + } else if (perms.permissions.includes("")) { + perms.origins.push(""); + } + }, + + /** + * Add new permissions for the given extension. `permissions` is + * in the format that is passed to browser.permissions.request(). + * + * @param {string} extensionId The extension id + * @param {object} perms Object with permissions and origins array. + * @param {EventEmitter} [emitter] optional object implementing emitter interfaces + */ + async add(extensionId, perms, emitter) { + let { permissions, origins } = await this._get(extensionId); + + let added = emptyPermissions(); + + this._fixupAllUrlsPerms(perms); + + for (let perm of perms.permissions) { + if (!permissions.includes(perm)) { + added.permissions.push(perm); + permissions.push(perm); + } + } + + for (let origin of perms.origins) { + origin = new MatchPattern(origin, { ignorePath: true }).pattern; + if (!origins.includes(origin)) { + added.origins.push(origin); + origins.push(origin); + } + } + + if (added.permissions.length || added.origins.length) { + await this._update(extensionId, { permissions, origins }); + lazy.Management.emit("change-permissions", { extensionId, added }); + if (emitter) { + emitter.emit("add-permissions", added); + } + } + }, + + /** + * Revoke permissions from the given extension. `permissions` is + * in the format that is passed to browser.permissions.request(). + * + * @param {string} extensionId The extension id + * @param {object} perms Object with permissions and origins array. + * @param {EventEmitter} [emitter] optional object implementing emitter interfaces + */ + async remove(extensionId, perms, emitter) { + let { permissions, origins } = await this._get(extensionId); + + let removed = emptyPermissions(); + + this._fixupAllUrlsPerms(perms); + + for (let perm of perms.permissions) { + let i = permissions.indexOf(perm); + if (i >= 0) { + removed.permissions.push(perm); + permissions.splice(i, 1); + } + } + + for (let origin of perms.origins) { + origin = new MatchPattern(origin, { ignorePath: true }).pattern; + + let i = origins.indexOf(origin); + if (i >= 0) { + removed.origins.push(origin); + origins.splice(i, 1); + } + } + + if (removed.permissions.length || removed.origins.length) { + await this._update(extensionId, { permissions, origins }); + lazy.Management.emit("change-permissions", { extensionId, removed }); + if (emitter) { + emitter.emit("remove-permissions", removed); + } + } + }, + + async removeAll(extensionId) { + lazy.StartupCache.permissions.delete(extensionId); + + let removed = store.get(extensionId); + await store.delete(extensionId); + lazy.Management.emit("change-permissions", { + extensionId, + removed: await removed, + }); + }, + + // This is meant for tests only + async _has(extensionId) { + return store.has(extensionId); + }, + + // This is meant for tests only + async _resetVersion() { + await store.resetVersionForTest(); + }, + + // This is meant for tests only + _useLegacyStorageBackend: false, + + // This is meant for tests only + async _uninit({ recreateStore = true } = {}) { + await store?.uninitForTest(); + store = null; + if (recreateStore) { + store = createStore(!this._useLegacyStorageBackend); + } + }, + + // This is meant for tests only + _getStore() { + return store; + }, + + // Convenience listener members for all permission changes. + addListener(listener) { + lazy.Management.on("change-permissions", listener); + }, + + removeListener(listener) { + lazy.Management.off("change-permissions", listener); + }, +}; + +export var OriginControls = { + allDomains: new MatchPattern("*://*/*"), + + /** + * @typedef {object} OriginControlState + * @param {boolean} noAccess no options, can never access host. + * @param {boolean} whenClicked option to access host when clicked. + * @param {boolean} alwaysOn option to always access this host. + * @param {boolean} allDomains option to access to all domains. + * @param {boolean} hasAccess extension currently has access to host. + * @param {boolean} temporaryAccess extension has temporary access to the tab. + */ + + /** + * Get origin controls state for a given extension on a given tab. + * + * @param {WebExtensionPolicy} policy + * @param {NativeTab} nativeTab + * @returns {OriginControlState} Extension origin controls for this host include: + */ + getState(policy, nativeTab) { + // Note: don't use the nativeTab directly because it's different on mobile. + let tab = policy?.extension?.tabManager.getWrapper(nativeTab); + let temporaryAccess = tab?.hasActiveTabPermission; + let uri = tab?.browser.currentURI; + + if (!uri) { + return { noAccess: true }; + } + + // activeTab and the resulting whenClicked state is only applicable for MV2 + // extensions with a browser action and MV3 extensions (with or without). + let activeTab = + policy.permissions.includes("activeTab") && + (policy.manifestVersion >= 3 || policy.extension?.hasBrowserActionUI); + + let couldRequest = policy.extension.optionalOrigins.matches(uri); + let hasAccess = policy.canAccessURI(uri); + + // If any of (MV2) content script patterns match the URI. + let csPatternMatches = false; + let quarantinedFrom = policy.quarantinedFromURI(uri); + + if (policy.manifestVersion < 3 && !hasAccess) { + csPatternMatches = policy.contentScripts.some(cs => + cs.matches.patterns.some(p => p.matches(uri)) + ); + // MV2 access through content scripts is implicit. + hasAccess = csPatternMatches && !quarantinedFrom; + } + + // If extension is quarantined from this host, but could otherwise have + // access (via activeTab, optional, allowedOrigins or content scripts). + let quarantined = + quarantinedFrom && + (activeTab || + couldRequest || + csPatternMatches || + policy.allowedOrigins.matches(uri)); + + if ( + quarantined || + !this.allDomains.matches(uri) || + WebExtensionPolicy.isRestrictedURI(uri) || + (!couldRequest && !hasAccess && !activeTab) + ) { + return { noAccess: true, quarantined }; + } + + if (!couldRequest && !hasAccess && activeTab) { + return { whenClicked: true, temporaryAccess }; + } + if (policy.allowedOrigins.subsumes(this.allDomains)) { + return { allDomains: true, hasAccess }; + } + + return { + whenClicked: true, + alwaysOn: true, + temporaryAccess, + hasAccess, + }; + }, + + /** + * Whether to show the attention indicator for extension on current tab. We + * usually show attention when: + * + * - some permissions are needed (in MV3+) + * - the extension is not allowed on the domain (quarantined) + * + * @param {WebExtensionPolicy} policy an extension's policy + * @param {Window} window The window for which we can get the attention state + * @returns {{attention: boolean, quarantined: boolean}} + */ + getAttentionState(policy, window) { + if (policy?.manifestVersion >= 3) { + const state = this.getState(policy, window.gBrowser.selectedTab); + // quarantined is always false when the feature is disabled. + const quarantined = !!state.quarantined; + const attention = + quarantined || + (!!state.whenClicked && !state.hasAccess && !state.temporaryAccess); + + return { attention, quarantined }; + } + + // No need to check whether the Quarantined Domains feature is enabled + // here, it's already done in `getState()`. + const state = this.getState(policy, window.gBrowser.selectedTab); + const attention = !!state.quarantined; + // If it needs attention, it's because of the quarantined domains. + return { attention, quarantined: attention }; + }, + + // Grant extension host permission to always run on this host. + setAlwaysOn(policy, uri) { + if (!policy.active) { + return; + } + let perms = { permissions: [], origins: ["*://" + uri.host] }; + return ExtensionPermissions.add(policy.id, perms, policy.extension); + }, + + // Revoke permission, extension should run only when clicked on this host. + setWhenClicked(policy, uri) { + if (!policy.active) { + return; + } + let perms = { permissions: [], origins: ["*://" + uri.host] }; + return ExtensionPermissions.remove(policy.id, perms, policy.extension); + }, + + /** + * @typedef {object} FluentIdInfo + * @param {string} default the message ID corresponding to the state + * that should be displayed by default. + * @param {string | null} onHover an optional message ID to be shown when + * users hover interactive elements (e.g. a + * button). + */ + + /** + * Get origin controls messages (fluent IDs) to be shown to users for a given + * extension on a given host. The messages might be different for extensions + * with a browser action (that might or might not open a popup). + * + * @param {object} params + * @param {WebExtensionPolicy} params.policy an extension's policy + * @param {NativeTab} params.tab the current tab + * @param {boolean} params.isAction this should be true for + * extensions with a browser + * action, false otherwise. + * @param {boolean} params.hasPopup this should be true when the + * browser action opens a popup, + * false otherwise. + * + * @returns {FluentIdInfo?} An object with origin controls message IDs or + * `null` when there is no message for the state. + */ + getStateMessageIDs({ policy, tab, isAction = false, hasPopup = false }) { + const state = this.getState(policy, tab); + + const onHoverForAction = hasPopup + ? "origin-controls-state-runnable-hover-open" + : "origin-controls-state-runnable-hover-run"; + + if (state.noAccess) { + return { + default: state.quarantined + ? "origin-controls-state-quarantined" + : "origin-controls-state-no-access", + onHover: isAction ? onHoverForAction : null, + }; + } + + if (state.allDomains || (state.alwaysOn && state.hasAccess)) { + return { + default: "origin-controls-state-always-on", + onHover: isAction ? onHoverForAction : null, + }; + } + + if (state.whenClicked) { + return { + default: state.temporaryAccess + ? "origin-controls-state-temporary-access" + : "origin-controls-state-when-clicked", + onHover: "origin-controls-state-hover-run-visit-only", + }; + } + + return null; + }, +}; + +export var QuarantinedDomains = { + getUserAllowedAddonIdPrefName(addonId) { + return `${this.PREF_ADDONS_BRANCH_NAME}${addonId}`; + }, + isUserAllowedAddonId(addonId) { + return Services.prefs.getBoolPref( + this.getUserAllowedAddonIdPrefName(addonId), + false + ); + }, + setUserAllowedAddonIdPref(addonId, userAllowed) { + Services.prefs.setBoolPref( + this.getUserAllowedAddonIdPrefName(addonId), + userAllowed + ); + }, + clearUserPref(addonId) { + Services.prefs.clearUserPref(this.getUserAllowedAddonIdPrefName(addonId)); + }, + + // Implementation internals. + + PREF_ADDONS_BRANCH_NAME: `extensions.quarantineIgnoredByUser.`, + PREF_DOMAINSLIST_NAME: `extensions.quarantinedDomains.list`, + _initialized: false, + _init() { + if (this._initialized) { + return; + } + + const onUserAllowedPrefChanged = this._onUserAllowedPrefChanged.bind(this); + Services.prefs.addObserver( + this.PREF_ADDONS_BRANCH_NAME, + onUserAllowedPrefChanged + ); + + const onUpdatedDomainsListTelemetry = + this._onUpdatedDomainsListTelemetry.bind(this); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "currentDomainsList", + this.PREF_DOMAINSLIST_NAME, + "", + onUpdatedDomainsListTelemetry, + value => this._transformDomainsListPrefValue(value || "") + ); + // Collect it at least once per session (and update it when the pref value changes). + onUpdatedDomainsListTelemetry(); + + const onAMRemoteSettingsSetPref = + this._onAMRemoteSettingsSetPref.bind(this); + Services.obs.addObserver( + onAMRemoteSettingsSetPref, + "am-remote-settings-setpref" + ); + + this._initialized = true; + }, + async _onAMRemoteSettingsSetPref(subject, _topic) { + const { prefName, prefValue } = subject?.wrappedJSObject ?? {}; + if (prefName !== this.PREF_DOMAINSLIST_NAME) { + return; + } + Glean.extensionsQuarantinedDomains.remotehash.set( + computeSha1HashAsString(prefValue || "") + ); + }, + async _onUserAllowedPrefChanged(_subject, _topic, prefName) { + let addonId = prefName.slice(this.PREF_ADDONS_BRANCH_NAME.length); + // Sanity check. + if (!addonId || prefName !== this.getUserAllowedAddonIdPrefName(addonId)) { + return; + } + + // Notify listeners, e.g. to update details in TelemetryEnvironment. + const addon = await lazy.AddonManager.getAddonByID(addonId); + // Do not call onPropertyChanged listeners if the addon cannot be found + // anymore (e.g. it has been uninstalled). + if (addon) { + lazy.AddonManagerPrivate.callAddonListeners("onPropertyChanged", addon, [ + "quarantineIgnoredByUser", + ]); + } + }, + _onUpdatedDomainsListTelemetry(_subject, _topic, _prefName) { + Glean.extensionsQuarantinedDomains.listsize.set( + this.currentDomainsList.set.size + ); + Glean.extensionsQuarantinedDomains.listhash.set( + this.currentDomainsList.hash + ); + }, + _transformDomainsListPrefValue(value) { + try { + return { + // NOTE: using a sha1 hash to make sure the resulting string will fit into the + // unified telemetry scalar string the glean metrics is mirrored to (which is + // limited to 50 characters). + hash: computeSha1HashAsString(value || ""), + set: new Set( + value + .split(",") + .map(v => v.trim()) + .filter(v => v.length) + ), + }; + } catch (err) { + return { hash: "unexpected-error", set: new Set() }; + } + }, +}; +QuarantinedDomains._init(); + +// Constants exported for testing purpose. +export { + OLD_JSON_FILENAME, + OLD_RKV_DIRNAME, + RKV_DIRNAME, + VERSION_KEY, + VERSION_VALUE, +}; diff --git a/toolkit/components/extensions/ExtensionPolicyService.cpp b/toolkit/components/extensions/ExtensionPolicyService.cpp new file mode 100644 index 0000000000..031abca444 --- /dev/null +++ b/toolkit/components/extensions/ExtensionPolicyService.cpp @@ -0,0 +1,762 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#include "mozilla/ExtensionPolicyService.h" +#include "mozilla/extensions/DocumentObserver.h" +#include "mozilla/extensions/WebExtensionContentScript.h" +#include "mozilla/extensions/WebExtensionPolicy.h" + +#include "mozilla/BasePrincipal.h" +#include "mozilla/ClearOnShutdown.h" +#include "mozilla/Preferences.h" +#include "mozilla/ResultExtensions.h" +#include "mozilla/Services.h" +#include "mozilla/SimpleEnumerator.h" +#include "mozilla/StaticPrefs_extensions.h" +#include "mozilla/Try.h" +#include "mozilla/dom/BrowsingContext.h" +#include "mozilla/dom/BrowsingContextGroup.h" +#include "mozilla/dom/ContentChild.h" +#include "mozilla/dom/ContentParent.h" +#include "mozilla/dom/Promise.h" +#include "mozilla/dom/Promise-inl.h" +#include "mozIExtensionProcessScript.h" +#include "nsEscape.h" +#include "nsGkAtoms.h" +#include "nsHashKeys.h" +#include "nsIChannel.h" +#include "nsIContentPolicy.h" +#include "mozilla/dom/Document.h" +#include "nsGlobalWindowInner.h" +#include "nsILoadInfo.h" +#include "nsIXULRuntime.h" +#include "nsImportModule.h" +#include "nsNetUtil.h" +#include "nsPrintfCString.h" +#include "nsPIDOMWindow.h" +#include "nsXULAppAPI.h" +#include "nsQueryObject.h" + +namespace mozilla { + +using namespace extensions; + +using dom::AutoJSAPI; +using dom::Document; +using dom::Promise; + +#define DEFAULT_CSP_PREF \ + "extensions.webextensions.default-content-security-policy" +#define DEFAULT_DEFAULT_CSP "script-src 'self' 'wasm-unsafe-eval';" + +#define DEFAULT_CSP_PREF_V3 \ + "extensions.webextensions.default-content-security-policy.v3" +#define DEFAULT_DEFAULT_CSP_V3 "script-src 'self'; upgrade-insecure-requests;" + +#define RESTRICTED_DOMAINS_PREF "extensions.webextensions.restrictedDomains" + +#define QUARANTINED_DOMAINS_PREF "extensions.quarantinedDomains.list" +#define QUARANTINED_DOMAINS_ENABLED "extensions.quarantinedDomains.enabled" + +#define OBS_TOPIC_PRELOAD_SCRIPT "web-extension-preload-content-script" +#define OBS_TOPIC_LOAD_SCRIPT "web-extension-load-content-script" + +static const char kDocElementInserted[] = "initial-document-element-inserted"; + +/***************************************************************************** + * ExtensionPolicyService + *****************************************************************************/ + +using CoreByHostMap = nsTHashMap>; + +static StaticRWLock sEPSLock; +static StaticAutoPtr sCoreByHost MOZ_GUARDED_BY(sEPSLock); +static StaticRefPtr sRestrictedDomains MOZ_GUARDED_BY(sEPSLock); +static StaticRefPtr sQuarantinedDomains MOZ_GUARDED_BY(sEPSLock); + +/* static */ +mozIExtensionProcessScript& ExtensionPolicyService::ProcessScript() { + static nsCOMPtr sProcessScript; + + MOZ_ASSERT(NS_IsMainThread()); + + if (MOZ_UNLIKELY(!sProcessScript)) { + sProcessScript = + do_ImportModule("resource://gre/modules/ExtensionProcessScript.jsm", + "ExtensionProcessScript"); + ClearOnShutdown(&sProcessScript); + } + return *sProcessScript; +} + +/* static */ ExtensionPolicyService& ExtensionPolicyService::GetSingleton() { + MOZ_ASSERT(NS_IsMainThread()); + + static RefPtr sExtensionPolicyService; + + if (MOZ_UNLIKELY(!sExtensionPolicyService)) { + sExtensionPolicyService = new ExtensionPolicyService(); + RegisterWeakMemoryReporter(sExtensionPolicyService); + ClearOnShutdown(&sExtensionPolicyService); + } + return *sExtensionPolicyService.get(); +} + +/* static */ +RefPtr +ExtensionPolicyService::GetCoreByHost(const nsACString& aHost) { + StaticAutoReadLock lock(sEPSLock); + return sCoreByHost ? sCoreByHost->Get(aHost) : nullptr; +} + +ExtensionPolicyService::ExtensionPolicyService() { + mObs = services::GetObserverService(); + MOZ_RELEASE_ASSERT(mObs); + + mDefaultCSP.SetIsVoid(true); + mDefaultCSPV3.SetIsVoid(true); + + RegisterObservers(); + + { + StaticAutoWriteLock lock(sEPSLock); + MOZ_DIAGNOSTIC_ASSERT(!sCoreByHost, + "ExtensionPolicyService created twice?"); + sCoreByHost = new CoreByHostMap(); + } + + UpdateRestrictedDomains(); + UpdateQuarantinedDomains(); +} + +ExtensionPolicyService::~ExtensionPolicyService() { + UnregisterWeakMemoryReporter(this); + + { + StaticAutoWriteLock lock(sEPSLock); + sCoreByHost = nullptr; + sRestrictedDomains = nullptr; + sQuarantinedDomains = nullptr; + } +} + +bool ExtensionPolicyService::UseRemoteExtensions() const { + static Maybe sRemoteExtensions; + if (MOZ_UNLIKELY(sRemoteExtensions.isNothing())) { + sRemoteExtensions = Some(StaticPrefs::extensions_webextensions_remote()); + } + return sRemoteExtensions.value() && BrowserTabsRemoteAutostart(); +} + +bool ExtensionPolicyService::IsExtensionProcess() const { + bool isRemote = UseRemoteExtensions(); + + if (isRemote && XRE_IsContentProcess()) { + auto& remoteType = dom::ContentChild::GetSingleton()->GetRemoteType(); + return remoteType == EXTENSION_REMOTE_TYPE; + } + return !isRemote && XRE_IsParentProcess(); +} + +bool ExtensionPolicyService::GetQuarantinedDomainsEnabled() const { + StaticAutoReadLock lock(sEPSLock); + return sQuarantinedDomains != nullptr; +} + +WebExtensionPolicy* ExtensionPolicyService::GetByURL(const URLInfo& aURL) { + if (aURL.Scheme() == nsGkAtoms::moz_extension) { + return GetByHost(aURL.Host()); + } + return nullptr; +} + +WebExtensionPolicy* ExtensionPolicyService::GetByHost( + const nsACString& aHost) const { + AssertIsOnMainThread(); + RefPtr core = GetCoreByHost(aHost); + return core ? core->GetMainThreadPolicy() : nullptr; +} + +void ExtensionPolicyService::GetAll( + nsTArray>& aResult) { + AppendToArray(aResult, mExtensions.Values()); +} + +bool ExtensionPolicyService::RegisterExtension(WebExtensionPolicy& aPolicy) { + bool ok = + (!GetByID(aPolicy.Id()) && !GetByHost(aPolicy.MozExtensionHostname())); + MOZ_ASSERT(ok); + + if (!ok) { + return false; + } + + mExtensions.InsertOrUpdate(aPolicy.Id(), RefPtr{&aPolicy}); + + { + StaticAutoWriteLock lock(sEPSLock); + sCoreByHost->InsertOrUpdate(aPolicy.MozExtensionHostname(), aPolicy.Core()); + } + return true; +} + +bool ExtensionPolicyService::UnregisterExtension(WebExtensionPolicy& aPolicy) { + bool ok = (GetByID(aPolicy.Id()) == &aPolicy && + GetByHost(aPolicy.MozExtensionHostname()) == &aPolicy); + MOZ_ASSERT(ok); + + if (!ok) { + return false; + } + + mExtensions.Remove(aPolicy.Id()); + + { + StaticAutoWriteLock lock(sEPSLock); + sCoreByHost->Remove(aPolicy.MozExtensionHostname()); + } + return true; +} + +bool ExtensionPolicyService::RegisterObserver(DocumentObserver& aObserver) { + bool inserted = false; + mObservers.LookupOrInsertWith(&aObserver, [&] { + inserted = true; + return RefPtr{&aObserver}; + }); + return inserted; +} + +bool ExtensionPolicyService::UnregisterObserver(DocumentObserver& aObserver) { + return mObservers.Remove(&aObserver); +} + +/***************************************************************************** + * nsIMemoryReporter + *****************************************************************************/ + +NS_IMETHODIMP +ExtensionPolicyService::CollectReports(nsIHandleReportCallback* aHandleReport, + nsISupports* aData, bool aAnonymize) { + for (const auto& ext : mExtensions.Values()) { + nsAtomCString id(ext->Id()); + + NS_ConvertUTF16toUTF8 name(ext->Name()); + name.ReplaceSubstring("\"", ""); + name.ReplaceSubstring("\\", ""); + + nsString url; + MOZ_TRY_VAR(url, ext->GetURL(u""_ns)); + + nsPrintfCString desc("Extension(id=%s, name=\"%s\", baseURL=%s)", id.get(), + name.get(), NS_ConvertUTF16toUTF8(url).get()); + desc.ReplaceChar('/', '\\'); + + nsCString path("extensions/"); + path.Append(desc); + + aHandleReport->Callback(""_ns, path, KIND_NONHEAP, UNITS_COUNT, 1, + "WebExtensions that are active in this session"_ns, + aData); + } + + return NS_OK; +} + +/***************************************************************************** + * Content script management + *****************************************************************************/ + +void ExtensionPolicyService::RegisterObservers() { + mObs->AddObserver(this, kDocElementInserted, false); + if (XRE_IsContentProcess()) { + mObs->AddObserver(this, "http-on-opening-request", false); + mObs->AddObserver(this, "document-on-opening-request", false); + } + + Preferences::AddStrongObserver(this, DEFAULT_CSP_PREF); + Preferences::AddStrongObserver(this, DEFAULT_CSP_PREF_V3); + Preferences::AddStrongObserver(this, RESTRICTED_DOMAINS_PREF); + Preferences::AddStrongObserver(this, QUARANTINED_DOMAINS_PREF); + Preferences::AddStrongObserver(this, QUARANTINED_DOMAINS_ENABLED); +} + +void ExtensionPolicyService::UnregisterObservers() { + mObs->RemoveObserver(this, kDocElementInserted); + if (XRE_IsContentProcess()) { + mObs->RemoveObserver(this, "http-on-opening-request"); + mObs->RemoveObserver(this, "document-on-opening-request"); + } + + Preferences::RemoveObserver(this, DEFAULT_CSP_PREF); + Preferences::RemoveObserver(this, DEFAULT_CSP_PREF_V3); + Preferences::RemoveObserver(this, RESTRICTED_DOMAINS_PREF); + Preferences::RemoveObserver(this, QUARANTINED_DOMAINS_PREF); + Preferences::RemoveObserver(this, QUARANTINED_DOMAINS_ENABLED); +} + +nsresult ExtensionPolicyService::Observe(nsISupports* aSubject, + const char* aTopic, + const char16_t* aData) { + if (!strcmp(aTopic, kDocElementInserted)) { + nsCOMPtr doc = do_QueryInterface(aSubject); + if (doc) { + CheckDocument(doc); + } + } else if (!strcmp(aTopic, "http-on-opening-request") || + !strcmp(aTopic, "document-on-opening-request")) { + nsCOMPtr chan = do_QueryInterface(aSubject); + if (chan) { + CheckRequest(chan); + } + } else if (!strcmp(aTopic, NS_PREFBRANCH_PREFCHANGE_TOPIC_ID)) { + const nsCString converted = NS_ConvertUTF16toUTF8(aData); + const char* pref = converted.get(); + if (!strcmp(pref, DEFAULT_CSP_PREF)) { + mDefaultCSP.SetIsVoid(true); + } else if (!strcmp(pref, DEFAULT_CSP_PREF_V3)) { + mDefaultCSPV3.SetIsVoid(true); + } else if (!strcmp(pref, RESTRICTED_DOMAINS_PREF)) { + UpdateRestrictedDomains(); + } else if (!strcmp(pref, QUARANTINED_DOMAINS_PREF) || + !strcmp(pref, QUARANTINED_DOMAINS_ENABLED)) { + UpdateQuarantinedDomains(); + } + } + return NS_OK; +} + +already_AddRefed ExtensionPolicyService::ExecuteContentScript( + nsPIDOMWindowInner* aWindow, WebExtensionContentScript& aScript) { + if (!aWindow->IsCurrentInnerWindow()) { + return nullptr; + } + + RefPtr promise; + ProcessScript().LoadContentScript(&aScript, aWindow, getter_AddRefs(promise)); + return promise.forget(); +} + +RefPtr ExtensionPolicyService::ExecuteContentScripts( + JSContext* aCx, nsPIDOMWindowInner* aWindow, + const nsTArray>& aScripts) { + AutoTArray, 8> promises; + + for (auto& script : aScripts) { + if (RefPtr promise = ExecuteContentScript(aWindow, *script)) { + promises.AppendElement(std::move(promise)); + } + } + + RefPtr promise = Promise::All(aCx, promises, IgnoreErrors()); + MOZ_RELEASE_ASSERT(promise); + return promise; +} + +// Use browser's MessageManagerGroup to decide if we care about it, to inject +// extension APIs or content scripts. Tabs use "browsers", and all custom +// extension browsers use "webext-browsers", including popups & sidebars, +// background & options pages, and xpcshell tests. +static bool IsTabOrExtensionBrowser(dom::BrowsingContext* aBC) { + const auto& group = aBC->Top()->GetMessageManagerGroup(); + bool rv = group == u"browsers"_ns || group == u"webext-browsers"_ns; + +#ifdef MOZ_THUNDERBIRD + // ...unless it's Thunderbird, which has extra groups for unrelated reasons. + rv = rv || group == u"single-site"_ns || group == u"single-page"_ns; +#endif + + return rv; +} + +static nsTArray> GetAllInProcessContentBCs() { + nsTArray> contentBCs; + nsTArray> groups; + dom::BrowsingContextGroup::GetAllGroups(groups); + for (const auto& group : groups) { + for (const auto& toplevel : group->Toplevels()) { + if (!toplevel->IsContent() || toplevel->IsDiscarded() || + !IsTabOrExtensionBrowser(toplevel)) { + continue; + } + + toplevel->PreOrderWalk([&](dom::BrowsingContext* aContext) { + contentBCs.AppendElement(aContext); + }); + } + } + return contentBCs; +} + +nsresult ExtensionPolicyService::InjectContentScripts( + WebExtensionPolicy* aExtension) { + AutoJSAPI jsapi; + MOZ_ALWAYS_TRUE(jsapi.Init(xpc::PrivilegedJunkScope())); + + auto contentBCs = GetAllInProcessContentBCs(); + for (dom::BrowsingContext* bc : contentBCs) { + auto* win = bc->GetDOMWindow(); + + if (bc->Top()->IsDiscarded() || !win || !win->GetDocumentURI()) { + continue; + } + DocInfo docInfo(win); + + using RunAt = dom::ContentScriptRunAt; + namespace RunAtValues = dom::ContentScriptRunAtValues; + using Scripts = AutoTArray, 8>; + + Scripts scripts[RunAtValues::Count]; + + auto GetScripts = [&](RunAt aRunAt) -> Scripts&& { + static_assert(sizeof(aRunAt) == 1, "Our cast is wrong"); + return std::move(scripts[uint8_t(aRunAt)]); + }; + + for (const auto& script : aExtension->ContentScripts()) { + if (script->Matches(docInfo)) { + GetScripts(script->RunAt()).AppendElement(script); + } + } + + nsCOMPtr inner = win->GetCurrentInnerWindow(); + + MOZ_TRY(ExecuteContentScripts(jsapi.cx(), inner, + GetScripts(RunAt::Document_start)) + ->ThenWithCycleCollectedArgs( + [](JSContext* aCx, JS::Handle aValue, + ErrorResult& aRv, ExtensionPolicyService* aSelf, + nsPIDOMWindowInner* aInner, Scripts&& aScripts) { + return aSelf->ExecuteContentScripts(aCx, aInner, aScripts) + .forget(); + }, + this, inner, GetScripts(RunAt::Document_end)) + .andThen([&](auto aPromise) { + return aPromise->ThenWithCycleCollectedArgs( + [](JSContext* aCx, JS::Handle aValue, + ErrorResult& aRv, ExtensionPolicyService* aSelf, + nsPIDOMWindowInner* aInner, Scripts&& aScripts) { + return aSelf + ->ExecuteContentScripts(aCx, aInner, aScripts) + .forget(); + }, + this, inner, GetScripts(RunAt::Document_idle)); + })); + } + return NS_OK; +} + +// Checks a request for matching content scripts, and begins pre-loading them +// if necessary. +void ExtensionPolicyService::CheckRequest(nsIChannel* aChannel) { + nsCOMPtr loadInfo = aChannel->LoadInfo(); + auto loadType = loadInfo->GetExternalContentPolicyType(); + if (loadType != ExtContentPolicy::TYPE_DOCUMENT && + loadType != ExtContentPolicy::TYPE_SUBDOCUMENT) { + return; + } + + nsCOMPtr uri; + if (NS_FAILED(aChannel->GetURI(getter_AddRefs(uri)))) { + return; + } + + CheckContentScripts({uri.get(), loadInfo}, true); +} + +static bool CheckParentFrames(nsPIDOMWindowOuter* aWindow, + WebExtensionPolicy& aPolicy) { + nsCOMPtr aboutAddons; + if (NS_FAILED(NS_NewURI(getter_AddRefs(aboutAddons), "about:addons"))) { + return false; + } + nsCOMPtr htmlAboutAddons; + if (NS_FAILED( + NS_NewURI(getter_AddRefs(htmlAboutAddons), + "chrome://mozapps/content/extensions/aboutaddons.html"))) { + return false; + } + + dom::WindowContext* wc = aWindow->GetCurrentInnerWindow()->GetWindowContext(); + while ((wc = wc->GetParentWindowContext())) { + if (!wc->IsInProcess()) { + return false; + } + + nsGlobalWindowInner* win = wc->GetInnerWindow(); + + auto* principal = BasePrincipal::Cast(win->GetPrincipal()); + if (principal->IsSystemPrincipal()) { + // The add-on manager is a special case, since it contains extension + // options pages in same-type frames. + nsIURI* uri = win->GetDocumentURI(); + bool equals; + if ((NS_SUCCEEDED(uri->Equals(aboutAddons, &equals)) && equals) || + (NS_SUCCEEDED(uri->Equals(htmlAboutAddons, &equals)) && equals)) { + return true; + } + } + + if (principal->AddonPolicy() != &aPolicy) { + return false; + } + } + + return true; +} + +// Checks a document, just after the document element has been inserted, for +// matching content scripts or extension principals, and loads them if +// necessary. +void ExtensionPolicyService::CheckDocument(Document* aDocument) { + nsCOMPtr win = aDocument->GetWindow(); + if (win) { + if (!IsTabOrExtensionBrowser(win->GetBrowsingContext())) { + return; + } + + if (win->GetDocumentURI()) { + CheckContentScripts(win.get(), false); + } + + nsIPrincipal* principal = aDocument->NodePrincipal(); + + RefPtr policy = + BasePrincipal::Cast(principal)->AddonPolicy(); + if (policy) { + bool privileged = IsExtensionProcess() && CheckParentFrames(win, *policy); + + ProcessScript().InitExtensionDocument(policy, aDocument, privileged); + } + } +} + +void ExtensionPolicyService::CheckContentScripts(const DocInfo& aDocInfo, + bool aIsPreload) { + nsCOMPtr win; + if (!aIsPreload) { + win = aDocInfo.GetWindow()->GetCurrentInnerWindow(); + } + + nsTArray> scriptsToLoad; + + for (RefPtr policy : mExtensions.Values()) { + for (auto& script : policy->ContentScripts()) { + if (script->Matches(aDocInfo)) { + if (aIsPreload) { + ProcessScript().PreloadContentScript(script); + } else { + // Collect the content scripts to load instead of loading them + // right away (to prevent a loaded content script from being + // able to invalidate the iterator by triggering a call to + // policy->UnregisterContentScript while we are still iterating + // over all its content scripts). See Bug 1593240. + scriptsToLoad.AppendElement(script); + } + } + } + + for (auto& script : scriptsToLoad) { + if (!win->IsCurrentInnerWindow()) { + break; + } + + RefPtr promise; + ProcessScript().LoadContentScript(script, win, getter_AddRefs(promise)); + } + + scriptsToLoad.ClearAndRetainStorage(); + } + + for (RefPtr observer : mObservers.Values()) { + for (auto& matcher : observer->Matchers()) { + if (matcher->Matches(aDocInfo)) { + if (aIsPreload) { + observer->NotifyMatch(*matcher, aDocInfo.GetLoadInfo()); + } else { + observer->NotifyMatch(*matcher, aDocInfo.GetWindow()); + } + } + } + } +} + +/* static */ +RefPtr ExtensionPolicyService::RestrictedDomains() { + StaticAutoReadLock lock(sEPSLock); + return sRestrictedDomains; +} + +/* static */ +RefPtr ExtensionPolicyService::QuarantinedDomains() { + StaticAutoReadLock lock(sEPSLock); + return sQuarantinedDomains; +} + +void ExtensionPolicyService::UpdateRestrictedDomains() { + nsAutoCString eltsString; + Unused << Preferences::GetCString(RESTRICTED_DOMAINS_PREF, eltsString); + + AutoTArray elts; + for (const nsACString& elt : eltsString.Split(',')) { + elts.AppendElement(NS_ConvertUTF8toUTF16(elt)); + elts.LastElement().StripWhitespace(); + } + RefPtr atomSet = new AtomSet(elts); + + StaticAutoWriteLock lock(sEPSLock); + sRestrictedDomains = atomSet; +} + +void ExtensionPolicyService::UpdateQuarantinedDomains() { + if (!Preferences::GetBool(QUARANTINED_DOMAINS_ENABLED)) { + StaticAutoWriteLock lock(sEPSLock); + sQuarantinedDomains = nullptr; + return; + } + + nsAutoCString eltsString; + AutoTArray elts; + if (NS_SUCCEEDED( + Preferences::GetCString(QUARANTINED_DOMAINS_PREF, eltsString))) { + for (const nsACString& elt : eltsString.Split(',')) { + elts.AppendElement(NS_ConvertUTF8toUTF16(elt)); + elts.LastElement().StripWhitespace(); + } + } + RefPtr atomSet = new AtomSet(elts); + + StaticAutoWriteLock lock(sEPSLock); + sQuarantinedDomains = atomSet; +} + +/***************************************************************************** + * nsIAddonPolicyService + *****************************************************************************/ + +nsresult ExtensionPolicyService::GetDefaultCSP(nsAString& aDefaultCSP) { + if (mDefaultCSP.IsVoid()) { + nsresult rv = Preferences::GetString(DEFAULT_CSP_PREF, mDefaultCSP); + if (NS_FAILED(rv)) { + mDefaultCSP.AssignLiteral(DEFAULT_DEFAULT_CSP); + } + mDefaultCSP.SetIsVoid(false); + } + + aDefaultCSP.Assign(mDefaultCSP); + return NS_OK; +} + +nsresult ExtensionPolicyService::GetDefaultCSPV3(nsAString& aDefaultCSP) { + if (mDefaultCSPV3.IsVoid()) { + nsresult rv = Preferences::GetString(DEFAULT_CSP_PREF_V3, mDefaultCSPV3); + if (NS_FAILED(rv)) { + mDefaultCSPV3.AssignLiteral(DEFAULT_DEFAULT_CSP_V3); + } + mDefaultCSPV3.SetIsVoid(false); + } + + aDefaultCSP.Assign(mDefaultCSPV3); + return NS_OK; +} + +nsresult ExtensionPolicyService::GetBaseCSP(const nsAString& aAddonId, + nsAString& aResult) { + if (WebExtensionPolicy* policy = GetByID(aAddonId)) { + policy->GetBaseCSP(aResult); + return NS_OK; + } + return NS_ERROR_INVALID_ARG; +} + +nsresult ExtensionPolicyService::GetExtensionPageCSP(const nsAString& aAddonId, + nsAString& aResult) { + if (WebExtensionPolicy* policy = GetByID(aAddonId)) { + policy->GetExtensionPageCSP(aResult); + return NS_OK; + } + return NS_ERROR_INVALID_ARG; +} + +nsresult ExtensionPolicyService::GetGeneratedBackgroundPageUrl( + const nsACString& aHostname, nsACString& aResult) { + if (WebExtensionPolicy* policy = GetByHost(aHostname)) { + nsAutoCString url("data:text/html,"); + + nsCString html = policy->BackgroundPageHTML(); + nsAutoCString escaped; + + url.Append(NS_EscapeURL(html, esc_Minimal, escaped)); + + aResult = url; + return NS_OK; + } + return NS_ERROR_INVALID_ARG; +} + +nsresult ExtensionPolicyService::AddonHasPermission(const nsAString& aAddonId, + const nsAString& aPerm, + bool* aResult) { + if (WebExtensionPolicy* policy = GetByID(aAddonId)) { + *aResult = policy->HasPermission(aPerm); + return NS_OK; + } + return NS_ERROR_INVALID_ARG; +} + +nsresult ExtensionPolicyService::AddonMayLoadURI(const nsAString& aAddonId, + nsIURI* aURI, bool aExplicit, + bool* aResult) { + if (WebExtensionPolicy* policy = GetByID(aAddonId)) { + *aResult = policy->CanAccessURI(aURI, aExplicit); + return NS_OK; + } + return NS_ERROR_INVALID_ARG; +} + +nsresult ExtensionPolicyService::GetExtensionName(const nsAString& aAddonId, + nsAString& aName) { + if (WebExtensionPolicy* policy = GetByID(aAddonId)) { + aName.Assign(policy->Name()); + return NS_OK; + } + return NS_ERROR_INVALID_ARG; +} + +nsresult ExtensionPolicyService::SourceMayLoadExtensionURI( + nsIURI* aSourceURI, nsIURI* aExtensionURI, bool* aResult) { + URLInfo source(aSourceURI); + URLInfo url(aExtensionURI); + if (WebExtensionPolicy* policy = GetByURL(url)) { + *aResult = policy->SourceMayAccessPath(source, url.FilePath()); + return NS_OK; + } + return NS_ERROR_INVALID_ARG; +} + +nsresult ExtensionPolicyService::ExtensionURIToAddonId(nsIURI* aURI, + nsAString& aResult) { + if (WebExtensionPolicy* policy = GetByURL(aURI)) { + policy->GetId(aResult); + } else { + aResult.SetIsVoid(true); + } + return NS_OK; +} + +NS_IMPL_CYCLE_COLLECTION(ExtensionPolicyService, mExtensions, mObservers) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ExtensionPolicyService) + NS_INTERFACE_MAP_ENTRY(nsIAddonPolicyService) + NS_INTERFACE_MAP_ENTRY(nsIObserver) + NS_INTERFACE_MAP_ENTRY(nsIMemoryReporter) + NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIAddonPolicyService) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(ExtensionPolicyService) +NS_IMPL_CYCLE_COLLECTING_RELEASE(ExtensionPolicyService) + +} // namespace mozilla diff --git a/toolkit/components/extensions/ExtensionPolicyService.h b/toolkit/components/extensions/ExtensionPolicyService.h new file mode 100644 index 0000000000..a767b48cd7 --- /dev/null +++ b/toolkit/components/extensions/ExtensionPolicyService.h @@ -0,0 +1,145 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#ifndef mozilla_ExtensionPolicyService_h +#define mozilla_ExtensionPolicyService_h + +#include "mozilla/MemoryReporting.h" +#include "mozilla/extensions/WebExtensionPolicy.h" +#include "mozIExtensionProcessScript.h" +#include "nsCOMPtr.h" +#include "nsCycleCollectionParticipant.h" +#include "nsHashKeys.h" +#include "nsIAddonPolicyService.h" +#include "nsAtom.h" +#include "nsIMemoryReporter.h" +#include "nsIObserver.h" +#include "nsIObserverService.h" +#include "nsISupports.h" +#include "nsPointerHashKeys.h" +#include "nsRefPtrHashtable.h" +#include "nsTHashSet.h" +#include "nsAtomHashKeys.h" + +class nsIChannel; +class nsIObserverService; + +class nsIPIDOMWindowInner; +class nsIPIDOMWindowOuter; + +namespace mozilla { +namespace dom { +class Promise; +} // namespace dom +namespace extensions { +class DocInfo; +class DocumentObserver; +class WebExtensionContentScript; +} // namespace extensions + +using extensions::DocInfo; +using extensions::WebExtensionPolicy; + +class ExtensionPolicyService final : public nsIAddonPolicyService, + public nsIObserver, + public nsIMemoryReporter { + public: + NS_DECL_CYCLE_COLLECTION_CLASS_AMBIGUOUS(ExtensionPolicyService, + nsIAddonPolicyService) + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_NSIADDONPOLICYSERVICE + NS_DECL_NSIOBSERVER + NS_DECL_NSIMEMORYREPORTER + + static mozIExtensionProcessScript& ProcessScript(); + + static ExtensionPolicyService& GetSingleton(); + + // Helper for fetching an AtomSet of restricted domains as configured by the + // extensions.webextensions.restrictedDomains pref. Safe to call from any + // thread. + static RefPtr RestrictedDomains(); + + // Thread-safe AtomSet from extensions.quarantinedDomains.list. + static RefPtr QuarantinedDomains(); + + static already_AddRefed GetInstance() { + return do_AddRef(&GetSingleton()); + } + + // Unlike the other methods on the ExtensionPolicyService, this method is + // threadsafe, and can look up a WebExtensionPolicyCore by hostname on any + // thread. + static RefPtr GetCoreByHost( + const nsACString& aHost); + + WebExtensionPolicy* GetByID(nsAtom* aAddonId) { + return mExtensions.GetWeak(aAddonId); + } + + WebExtensionPolicy* GetByID(const nsAString& aAddonId) { + RefPtr atom = NS_AtomizeMainThread(aAddonId); + return GetByID(atom); + } + + WebExtensionPolicy* GetByURL(const extensions::URLInfo& aURL); + + WebExtensionPolicy* GetByHost(const nsACString& aHost) const; + + void GetAll(nsTArray>& aResult); + + bool RegisterExtension(WebExtensionPolicy& aPolicy); + bool UnregisterExtension(WebExtensionPolicy& aPolicy); + + bool RegisterObserver(extensions::DocumentObserver& aPolicy); + bool UnregisterObserver(extensions::DocumentObserver& aPolicy); + + bool UseRemoteExtensions() const; + bool IsExtensionProcess() const; + bool GetQuarantinedDomainsEnabled() const; + + nsresult InjectContentScripts(WebExtensionPolicy* aExtension); + + protected: + virtual ~ExtensionPolicyService(); + + private: + ExtensionPolicyService(); + + void RegisterObservers(); + void UnregisterObservers(); + + void CheckRequest(nsIChannel* aChannel); + void CheckDocument(dom::Document* aDocument); + + void CheckContentScripts(const DocInfo& aDocInfo, bool aIsPreload); + + already_AddRefed ExecuteContentScript( + nsPIDOMWindowInner* aWindow, + extensions::WebExtensionContentScript& aScript); + + RefPtr ExecuteContentScripts( + JSContext* aCx, nsPIDOMWindowInner* aWindow, + const nsTArray>& aScripts); + + void UpdateRestrictedDomains(); + void UpdateQuarantinedDomains(); + + // The WebExtensionPolicy object keeps the key alive. + nsRefPtrHashtable mExtensions; + + nsRefPtrHashtable, + extensions::DocumentObserver> + mObservers; + + nsCOMPtr mObs; + + nsString mDefaultCSP; + nsString mDefaultCSPV3; +}; + +} // namespace mozilla + +#endif // mozilla_ExtensionPolicyService_h diff --git a/toolkit/components/extensions/ExtensionPreferencesManager.sys.mjs b/toolkit/components/extensions/ExtensionPreferencesManager.sys.mjs new file mode 100644 index 0000000000..b2b7bbac96 --- /dev/null +++ b/toolkit/components/extensions/ExtensionPreferencesManager.sys.mjs @@ -0,0 +1,712 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +/** + * @file + * This module is used for managing preferences from WebExtension APIs. + * It takes care of the precedence chain and decides whether a preference + * needs to be updated when a change is requested by an API. + * + * It deals with preferences via settings objects, which are objects with + * the following properties: + * + * prefNames: An array of strings, each of which is a preference on + * which the setting depends. + * setCallback: A function that returns an object containing properties and + * values that correspond to the prefs to be set. + */ + +export let ExtensionPreferencesManager; + +import { Management } from "resource://gre/modules/Extension.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", + ExtensionSettingsStore: + "resource://gre/modules/ExtensionSettingsStore.sys.mjs", + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { ExtensionError } = ExtensionUtils; + +ChromeUtils.defineLazyGetter(lazy, "defaultPreferences", function () { + return new lazy.Preferences({ defaultBranch: true }); +}); + +/* eslint-disable mozilla/balanced-listeners */ +Management.on("uninstall", async (type, { id }) => { + // Ensure managed preferences are cleared if they were + // not cleared at the module level. + await Management.asyncLoadSettingsModules(); + return ExtensionPreferencesManager.removeAll(id); +}); + +Management.on("disable", async (type, id) => { + await Management.asyncLoadSettingsModules(); + return ExtensionPreferencesManager.disableAll(id); +}); + +Management.on("enabling", async (type, id) => { + await Management.asyncLoadSettingsModules(); + return ExtensionPreferencesManager.enableAll(id); +}); + +Management.on("change-permissions", (type, change) => { + // Called for added or removed, but we only care about removed here. + if (!change.removed) { + return; + } + ExtensionPreferencesManager.removeSettingsForPermissions( + change.extensionId, + change.removed.permissions + ); +}); + +/* eslint-enable mozilla/balanced-listeners */ + +const STORE_TYPE = "prefs"; + +// Definitions of settings, each of which correspond to a different API. +let settingsMap = new Map(); + +/** + * This function is passed into the ExtensionSettingsStore to determine the + * initial value of the setting. It reads an array of preference names from + * the this scope, which gets bound to a settings object. + * + * @returns {object} + * An object with one property per preference, which holds the current + * value of that preference. + */ +function initialValueCallback() { + let initialValue = {}; + for (let pref of this.prefNames) { + // If there is a prior user-set value, get it. + if (lazy.Preferences.isSet(pref)) { + initialValue[pref] = lazy.Preferences.get(pref); + } + } + return initialValue; +} + +/** + * Updates the initialValue stored to exclude any values that match + * default preference values. + * + * @param {object} initialValue Initial Value data from settings store. + * @returns {object} + * The initialValue object after updating the values. + */ +function settingsUpdate(initialValue) { + for (let pref of this.prefNames) { + try { + if ( + initialValue[pref] !== undefined && + initialValue[pref] === lazy.defaultPreferences.get(pref) + ) { + initialValue[pref] = undefined; + } + } catch (e) { + // Exception thrown if a default value doesn't exist. We + // presume that this pref had a user-set value initially. + } + } + return initialValue; +} + +/** + * Loops through a set of prefs, either setting or resetting them. + * + * @param {string} name + * The api name of the setting. + * @param {object} setting + * An object that represents a setting, which will have a setCallback + * property. If a onPrefsChanged function is provided it will be called + * with item when the preferences change. + * @param {object} item + * An object that represents an item handed back from the setting store + * from which the new pref values can be calculated. + */ +function setPrefs(name, setting, item) { + let prefs = item.initialValue || setting.setCallback(item.value); + let changed = false; + for (let pref of setting.prefNames) { + if (prefs[pref] === undefined) { + if (lazy.Preferences.isSet(pref)) { + changed = true; + lazy.Preferences.reset(pref); + } + } else if (lazy.Preferences.get(pref) != prefs[pref]) { + lazy.Preferences.set(pref, prefs[pref]); + changed = true; + } + } + if (changed && typeof setting.onPrefsChanged == "function") { + setting.onPrefsChanged(item); + } + Management.emit(`extension-setting-changed:${name}`); +} + +/** + * Commits a change to a setting and conditionally sets preferences. + * + * If the change to the setting causes a different extension to gain + * control of the pref (or removes all extensions with control over the pref) + * then the prefs should be updated, otherwise they should not be. + * In addition, if the current value of any of the prefs does not + * match what we expect the value to be (which could be the result of a + * user manually changing the pref value), then we do not change any + * of the prefs. + * + * @param {string} id + * The id of the extension for which a setting is being modified. Also + * see selectSetting. + * @param {string} name + * The name of the setting being processed. + * @param {string} action + * The action that is being performed. Will be one of disable, enable + * or removeSetting. + + * @returns {Promise} + * Resolves to true if preferences were set as a result and to false + * if preferences were not set. +*/ +async function processSetting(id, name, action) { + await lazy.ExtensionSettingsStore.initialize(); + let expectedItem = lazy.ExtensionSettingsStore.getSetting(STORE_TYPE, name); + let item = lazy.ExtensionSettingsStore[action](id, STORE_TYPE, name); + if (item) { + let setting = settingsMap.get(name); + let expectedPrefs = + expectedItem.initialValue || setting.setCallback(expectedItem.value); + if ( + Object.keys(expectedPrefs).some( + pref => + expectedPrefs[pref] && + lazy.Preferences.get(pref) != expectedPrefs[pref] + ) + ) { + return false; + } + setPrefs(name, setting, item); + return true; + } + return false; +} + +ExtensionPreferencesManager = { + /** + * Adds a setting to the settingsMap. This is how an API tells the + * preferences manager what its setting object is. The preferences + * manager needs to know this when settings need to be removed + * automatically. + * + * @param {string} name The unique id of the setting. + * @param {object} setting + * A setting object that should have properties for + * prefNames, getCallback and setCallback. + */ + addSetting(name, setting) { + settingsMap.set(name, setting); + }, + + /** + * Gets the default value for a preference. + * + * @param {string} prefName The name of the preference. + * + * @returns {string|number|boolean} The default value of the preference. + */ + getDefaultValue(prefName) { + return lazy.defaultPreferences.get(prefName); + }, + + /** + * Returns a map of prefName to setting Name for use in about:config, about:preferences or + * other areas of Firefox that need to know whether a specific pref is controlled by an + * extension. + * + * Given a prefName, you can get the settingName. Call EPM.getSetting(settingName) to + * get the details of the setting, including which id if any is in control of the + * setting. + * + * @returns {Promise} + * Resolves to a Map of prefName->settingName + */ + async getManagedPrefDetails() { + await Management.asyncLoadSettingsModules(); + let prefs = new Map(); + settingsMap.forEach((setting, name) => { + for (let prefName of setting.prefNames) { + prefs.set(prefName, name); + } + }); + return prefs; + }, + + /** + * Indicates that an extension would like to change the value of a previously + * defined setting. + * + * @param {string} id + * The id of the extension for which a setting is being set. + * @param {string} name + * The unique id of the setting. + * @param {any} value + * The value to be stored in the settings store for this + * group of preferences. + * + * @returns {Promise} + * Resolves to true if the preferences were changed and to false if + * the preferences were not changed. + */ + async setSetting(id, name, value) { + let setting = settingsMap.get(name); + await lazy.ExtensionSettingsStore.initialize(); + let item = await lazy.ExtensionSettingsStore.addSetting( + id, + STORE_TYPE, + name, + value, + initialValueCallback.bind(setting), + name, + settingsUpdate.bind(setting) + ); + if (item) { + setPrefs(name, setting, item); + return true; + } + return false; + }, + + /** + * Indicates that this extension wants to temporarily cede control over the + * given setting. + * + * @param {string} id + * The id of the extension for which a preference setting is being disabled. + * @param {string} name + * The unique id of the setting. + * + * @returns {Promise} + * Resolves to true if the preferences were changed and to false if + * the preferences were not changed. + */ + disableSetting(id, name) { + return processSetting(id, name, "disable"); + }, + + /** + * Enable a setting that has been disabled. + * + * @param {string} id + * The id of the extension for which a setting is being enabled. + * @param {string} name + * The unique id of the setting. + * + * @returns {Promise} + * Resolves to true if the preferences were changed and to false if + * the preferences were not changed. + */ + enableSetting(id, name) { + return processSetting(id, name, "enable"); + }, + + /** + * Specifically select an extension, the user, or the precedence order that will + * be in control of this setting. + * + * @param {string | null} id + * The id of the extension for which a setting is being selected, or + * ExtensionSettingStore.SETTING_USER_SET (null). + * @param {string} name + * The unique id of the setting. + * + * @returns {Promise} + * Resolves to true if the preferences were changed and to false if + * the preferences were not changed. + */ + selectSetting(id, name) { + return processSetting(id, name, "select"); + }, + + /** + * Indicates that this extension no longer wants to set the given setting. + * + * @param {string} id + * The id of the extension for which a preference setting is being removed. + * @param {string} name + * The unique id of the setting. + * + * @returns {Promise} + * Resolves to true if the preferences were changed and to false if + * the preferences were not changed. + */ + removeSetting(id, name) { + return processSetting(id, name, "removeSetting"); + }, + + /** + * Disables all previously set settings for an extension. This can be called when + * an extension is being disabled, for example. + * + * @param {string} id + * The id of the extension for which all settings are being unset. + */ + async disableAll(id) { + await lazy.ExtensionSettingsStore.initialize(); + let settings = lazy.ExtensionSettingsStore.getAllForExtension( + id, + STORE_TYPE + ); + let disablePromises = []; + for (let name of settings) { + disablePromises.push(this.disableSetting(id, name)); + } + await Promise.all(disablePromises); + }, + + /** + * Enables all disabled settings for an extension. This can be called when + * an extension has finished updating or is being re-enabled, for example. + * + * @param {string} id + * The id of the extension for which all settings are being enabled. + */ + async enableAll(id) { + await lazy.ExtensionSettingsStore.initialize(); + let settings = lazy.ExtensionSettingsStore.getAllForExtension( + id, + STORE_TYPE + ); + let enablePromises = []; + for (let name of settings) { + enablePromises.push(this.enableSetting(id, name)); + } + await Promise.all(enablePromises); + }, + + /** + * Removes all previously set settings for an extension. This can be called when + * an extension is being uninstalled, for example. + * + * @param {string} id + * The id of the extension for which all settings are being unset. + */ + async removeAll(id) { + await lazy.ExtensionSettingsStore.initialize(); + let settings = lazy.ExtensionSettingsStore.getAllForExtension( + id, + STORE_TYPE + ); + let removePromises = []; + for (let name of settings) { + removePromises.push(this.removeSetting(id, name)); + } + await Promise.all(removePromises); + }, + + /** + * Removes a set of settings that are available under certain addon permissions. + * + * @param {string} id + * The extension id. + * @param {Array} permissions + * The permission name from the extension manifest. + * @returns {Promise} + * A promise that resolves when all related settings are removed. + */ + async removeSettingsForPermissions(id, permissions) { + if (!permissions || !permissions.length) { + return; + } + await Management.asyncLoadSettingsModules(); + let removePromises = []; + settingsMap.forEach((setting, name) => { + if (permissions.includes(setting.permission)) { + removePromises.push(this.removeSetting(id, name)); + } + }); + return Promise.all(removePromises); + }, + + /** + * Return the currently active value for a setting. + * + * @param {string} name + * The unique id of the setting. + * + * @returns {Promise} The current setting object. + */ + async getSetting(name) { + await lazy.ExtensionSettingsStore.initialize(); + return lazy.ExtensionSettingsStore.getSetting(STORE_TYPE, name); + }, + + /** + * Return the levelOfControl for a setting / extension combo. + * This queries the levelOfControl from the ExtensionSettingsStore and also + * takes into account whether any of the setting's preferences are locked. + * + * @param {string} id + * The id of the extension for which levelOfControl is being requested. + * @param {string} name + * The unique id of the setting. + * @param {string} storeType + * The name of the store in ExtensionSettingsStore. + * Defaults to STORE_TYPE. + * + * @returns {Promise} + * Resolves to the level of control of the extension over the setting. + */ + async getLevelOfControl(id, name, storeType = STORE_TYPE) { + // This could be called for a setting that isn't defined to the PreferencesManager, + // in which case we simply defer to the SettingsStore. + if (storeType === STORE_TYPE) { + let setting = settingsMap.get(name); + if (!setting) { + return "not_controllable"; + } + for (let prefName of setting.prefNames) { + if (lazy.Preferences.locked(prefName)) { + return "not_controllable"; + } + } + } + await lazy.ExtensionSettingsStore.initialize(); + return lazy.ExtensionSettingsStore.getLevelOfControl(id, storeType, name); + }, + + /** + * Returns an API object with get/set/clear used for a setting. + * + * @param {string|object} extensionId or params object + * @param {string} name + * The unique id of the setting. + * @param {Function} callback + * The function that retreives the current setting from prefs. + * @param {string} storeType + * The name of the store in ExtensionSettingsStore. + * Defaults to STORE_TYPE. + * @param {boolean} readOnly + * @param {Function} validate + * Utility function for any specific validation, such as checking + * for supported platform. Function should throw an error if necessary. + * + * @returns {object} API object with get/set/clear methods + */ + getSettingsAPI( + extensionId, + name, + callback, + storeType, + readOnly = false, + validate + ) { + if (arguments.length > 1) { + Services.console.logStringMessage( + `ExtensionPreferencesManager.getSettingsAPI for ${name} should be updated to use a single paramater object.` + ); + } + return ExtensionPreferencesManager._getInternalSettingsAPI( + arguments.length === 1 + ? extensionId + : { + extensionId, + name, + callback, + storeType, + readOnly, + validate, + } + ).api; + }, + + /** + * getPrimedSettingsListener returns a function used to create + * a primed event listener. + * + * If a module overrides onChange then it must provide it's own + * persistent listener logic. See homepage_override in browserSettings + * for an example. + * + * addSetting must be called prior to priming listeners. + * + * @param {object} config see getSettingsAPI + * {Extension} extension, passed through to validate and used for extensionId + * {string} name + * The unique id of the settings api in the module, e.g. "settings" + * @returns {object} prime listener object + */ + getPrimedSettingsListener(config) { + let { name, extension } = config; + if (!name || !extension) { + throw new Error( + `name and extension are required for getPrimedSettingListener` + ); + } + if (!settingsMap.get(name)) { + throw new Error( + `addSetting must be called prior to getPrimedSettingListener` + ); + } + return ExtensionPreferencesManager._getInternalSettingsAPI({ + name, + extension, + }).registerEvent; + }, + + /** + * Returns an object with a public API containing get/set/clear used for a setting, + * and a registerEvent function used for registering the event listener. + * + * @param {object} params The params object contains the following: + * {BaseContext} context + * {Extension} extension, optional, passed through to validate and used for extensionId + * {string} extensionId, optional to support old API + * {string} module + * The name of the api module, e.g. "proxy" + * {string} name + * The unique id of the settings api in the module, e.g. "settings" + * "name" should match the name given in the addSetting call. + * {Function} callback + * The function that retreives the current setting from prefs. + * {string} storeType + * The name of the store in ExtensionSettingsStore. + * Defaults to STORE_TYPE. + * {boolean} readOnly + * {Function} validate + * Utility function for any specific validation, such as checking + * for supported platform. Function should throw an error if necessary. + * + * @returns {object} internal API object with + * {object} api + * the public api available to extensions + * {Function} registerEvent + * the registration function used for priming events + */ + _getInternalSettingsAPI(params) { + let { + extensionId, + context, + extension, + module, + name, + callback, + storeType, + readOnly = false, + onChange, + validate, + } = params; + if (context) { + extension = context.extension; + } + if (!extensionId && extension) { + extensionId = extension.id; + } + + const checkScope = details => { + let { scope } = details; + if (scope && scope !== "regular") { + throw new ExtensionError( + `Firefox does not support the ${scope} settings scope.` + ); + } + }; + + // Check the setting for anything we may need. + let setting = settingsMap.get(name); + readOnly = readOnly || !!setting?.readOnly; + validate = validate || setting?.validate || (() => {}); + let getValue = callback || setting?.getCallback; + if (!getValue || typeof getValue !== "function") { + throw new Error(`Invalid get callback for setting ${name} in ${module}`); + } + + let settingsAPI = { + async get(details) { + validate(extension); + let levelOfControl = details.incognito + ? "not_controllable" + : await ExtensionPreferencesManager.getLevelOfControl( + extensionId, + name, + storeType + ); + levelOfControl = + readOnly && levelOfControl === "controllable_by_this_extension" + ? "not_controllable" + : levelOfControl; + return { + levelOfControl, + value: await getValue(), + }; + }, + set(details) { + validate(extension); + checkScope(details); + if (!readOnly) { + return ExtensionPreferencesManager.setSetting( + extensionId, + name, + details.value + ); + } + return false; + }, + clear(details) { + validate(extension); + checkScope(details); + if (!readOnly) { + return ExtensionPreferencesManager.removeSetting(extensionId, name); + } + return false; + }, + onChange, + }; + let registerEvent = fire => { + let listener = async () => { + fire.async(await settingsAPI.get({})); + }; + Management.on(`extension-setting-changed:${name}`, listener); + return { + unregister: () => { + Management.off(`extension-setting-changed:${name}`, listener); + }, + convert(_fire) { + fire = _fire; + }, + }; + }; + + // Any caller using the old call signature will not have passed + // context to us. This should only be experimental addons in the + // wild. + if (onChange === undefined && context) { + // Some settings that are read-only may not have called addSetting, in + // which case we have no way to listen on the pref changes. + if (setting) { + settingsAPI.onChange = new lazy.ExtensionCommon.EventManager({ + context, + module, + event: name, + name: `${name}.onChange`, + register: fire => { + return registerEvent(fire).unregister; + }, + }).api(); + } else { + Services.console.logStringMessage( + `ExtensionPreferencesManager API ${name} created but addSetting was not called.` + ); + } + } + return { api: settingsAPI, registerEvent }; + }, +}; diff --git a/toolkit/components/extensions/ExtensionProcessScript.sys.mjs b/toolkit/components/extensions/ExtensionProcessScript.sys.mjs new file mode 100644 index 0000000000..2fcf113a88 --- /dev/null +++ b/toolkit/components/extensions/ExtensionProcessScript.sys.mjs @@ -0,0 +1,525 @@ +/* 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/. */ + +/** + * This script contains the minimum, skeleton content process code that we need + * in order to lazily load other extension modules when they are first + * necessary. Anything which is not likely to be needed immediately, or shortly + * after startup, in *every* browser process live outside of this file. + */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionChild: "resource://gre/modules/ExtensionChild.sys.mjs", + ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", + ExtensionContent: "resource://gre/modules/ExtensionContent.sys.mjs", + ExtensionPageChild: "resource://gre/modules/ExtensionPageChild.sys.mjs", + ExtensionWorkerChild: "resource://gre/modules/ExtensionWorkerChild.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", +}); + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { DefaultWeakMap } = ExtensionUtils; + +const { sharedData } = Services.cpmm; + +function getData(extension, key = "") { + return sharedData.get(`extension/${extension.id}/${key}`); +} + +// We need to avoid touching Services.appinfo here in order to prevent +// the wrong version from being cached during xpcshell test startup. +// eslint-disable-next-line mozilla/use-services +ChromeUtils.defineLazyGetter(lazy, "isContentProcess", () => { + return Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT; +}); + +ChromeUtils.defineLazyGetter(lazy, "isContentScriptProcess", () => { + return ( + lazy.isContentProcess || + !WebExtensionPolicy.useRemoteWebExtensions || + // Thunderbird still loads some content in the parent process. + AppConstants.MOZ_APP_NAME == "thunderbird" + ); +}); + +var extensions = new DefaultWeakMap(policy => { + return new lazy.ExtensionChild.BrowserExtensionContent(policy); +}); + +var pendingExtensions = new Map(); + +var ExtensionManager; + +ExtensionManager = { + // WeakMap> + registeredContentScripts: new DefaultWeakMap(policy => new Map()), + + init() { + Services.cpmm.addMessageListener("Extension:Startup", this); + Services.cpmm.addMessageListener("Extension:Shutdown", this); + Services.cpmm.addMessageListener("Extension:FlushJarCache", this); + Services.cpmm.addMessageListener("Extension:RegisterContentScripts", this); + Services.cpmm.addMessageListener( + "Extension:UnregisterContentScripts", + this + ); + Services.cpmm.addMessageListener("Extension:UpdateContentScripts", this); + Services.cpmm.addMessageListener("Extension:UpdatePermissions", this); + Services.cpmm.addMessageListener("Extension:UpdateIgnoreQuarantine", this); + + this.updateStubExtensions(); + + for (let id of sharedData.get("extensions/activeIDs") || []) { + this.initExtension(getData({ id })); + } + }, + + initStubPolicy(id, data) { + let resolveReadyPromise; + let readyPromise = new Promise(resolve => { + resolveReadyPromise = resolve; + }); + + let policy = new WebExtensionPolicy({ + id, + localizeCallback() {}, + readyPromise, + allowedOrigins: new MatchPatternSet([]), + ...data, + }); + + try { + policy.active = true; + + pendingExtensions.set(id, { policy, resolveReadyPromise }); + } catch (e) { + Cu.reportError(e); + } + }, + + updateStubExtensions() { + for (let [id, data] of sharedData.get("extensions/pending") || []) { + if (!pendingExtensions.has(id)) { + this.initStubPolicy(id, data); + } + } + }, + + initExtensionPolicy(extension) { + let policy = WebExtensionPolicy.getByID(extension.id); + if (!policy || pendingExtensions.has(extension.id)) { + let localizeCallback; + if (extension.localize) { + // We have a real Extension object. + localizeCallback = extension.localize.bind(extension); + } else { + // We have serialized extension data; + localizeCallback = str => extensions.get(policy).localize(str); + } + + let { backgroundScripts } = extension; + if (!backgroundScripts && WebExtensionPolicy.isExtensionProcess) { + ({ backgroundScripts } = getData(extension, "extendedData") || {}); + } + + let { backgroundWorkerScript } = extension; + if (!backgroundWorkerScript && WebExtensionPolicy.isExtensionProcess) { + ({ backgroundWorkerScript } = getData(extension, "extendedData") || {}); + } + + let { backgroundTypeModule } = extension; + if ( + backgroundTypeModule == null && + WebExtensionPolicy.isExtensionProcess + ) { + ({ backgroundTypeModule } = getData(extension, "extendedData") || {}); + } + + policy = new WebExtensionPolicy({ + id: extension.id, + mozExtensionHostname: extension.uuid, + name: extension.name, + type: extension.type, + baseURL: extension.resourceURL, + + isPrivileged: extension.isPrivileged, + ignoreQuarantine: extension.ignoreQuarantine, + temporarilyInstalled: extension.temporarilyInstalled, + permissions: extension.permissions, + allowedOrigins: extension.allowedOrigins, + webAccessibleResources: extension.webAccessibleResources, + + manifestVersion: extension.manifestVersion, + extensionPageCSP: extension.extensionPageCSP, + + localizeCallback, + + backgroundScripts, + backgroundWorkerScript, + backgroundTypeModule, + + contentScripts: extension.contentScripts, + }); + + policy.debugName = `${JSON.stringify(policy.name)} (ID: ${ + policy.id + }, ${policy.getURL()})`; + + // Register any existent dynamically registered content script for the extension + // when a content process is started for the first time (which also cover + // a content process that crashed and it has been recreated). + const registeredContentScripts = + this.registeredContentScripts.get(policy); + + for (let [scriptId, options] of getData(extension, "contentScripts") || + []) { + const script = new WebExtensionContentScript(policy, options); + + // If the script is a userScript, add the additional userScriptOptions + // property to the WebExtensionContentScript instance. + if ("userScriptOptions" in options) { + script.userScriptOptions = options.userScriptOptions; + } + + policy.registerContentScript(script); + registeredContentScripts.set(scriptId, script); + } + + let stub = pendingExtensions.get(extension.id); + if (stub) { + pendingExtensions.delete(extension.id); + stub.policy.active = false; + stub.resolveReadyPromise(policy); + } + + policy.active = true; + policy.instanceId = extension.instanceId; + policy.optionalPermissions = extension.optionalPermissions; + } + return policy; + }, + + initExtension(data) { + if (typeof data === "string") { + data = getData({ id: data }); + } + let policy = this.initExtensionPolicy(data); + + policy.injectContentScripts(); + }, + + handleEvent(event) { + if ( + event.type === "change" && + event.changedKeys.includes("extensions/pending") + ) { + this.updateStubExtensions(); + } + }, + + receiveMessage({ name, data }) { + try { + switch (name) { + case "Extension:Startup": + this.initExtension(data); + break; + + case "Extension:Shutdown": { + let policy = WebExtensionPolicy.getByID(data.id); + if (policy) { + if (extensions.has(policy)) { + extensions.get(policy).shutdown(); + } + + if (lazy.isContentProcess) { + policy.active = false; + } + } + break; + } + + case "Extension:FlushJarCache": + ExtensionUtils.flushJarCache(data.path); + break; + + case "Extension:RegisterContentScripts": { + let policy = WebExtensionPolicy.getByID(data.id); + + if (policy) { + const registeredContentScripts = + this.registeredContentScripts.get(policy); + + for (const { scriptId, options } of data.scripts) { + const type = + "userScriptOptions" in options ? "userScript" : "contentScript"; + + if (registeredContentScripts.has(scriptId)) { + Cu.reportError( + new Error( + `Registering ${type} ${scriptId} on ${data.id} more than once` + ) + ); + } else { + const script = new WebExtensionContentScript(policy, options); + + // If the script is a userScript, add the additional + // userScriptOptions property to the WebExtensionContentScript + // instance. + if (type === "userScript") { + script.userScriptOptions = options.userScriptOptions; + } + + policy.registerContentScript(script); + registeredContentScripts.set(scriptId, script); + } + } + } + break; + } + + case "Extension:UnregisterContentScripts": { + let policy = WebExtensionPolicy.getByID(data.id); + + if (policy) { + const registeredContentScripts = + this.registeredContentScripts.get(policy); + + for (const scriptId of data.scriptIds) { + const script = registeredContentScripts.get(scriptId); + if (script) { + policy.unregisterContentScript(script); + registeredContentScripts.delete(scriptId); + } + } + } + break; + } + + case "Extension:UpdateContentScripts": { + let policy = WebExtensionPolicy.getByID(data.id); + + if (policy) { + const registeredContentScripts = + this.registeredContentScripts.get(policy); + + for (const { scriptId, options } of data.scripts) { + const oldScript = registeredContentScripts.get(scriptId); + const newScript = new WebExtensionContentScript(policy, options); + + policy.unregisterContentScript(oldScript); + policy.registerContentScript(newScript); + registeredContentScripts.set(scriptId, newScript); + } + } + break; + } + + case "Extension:UpdatePermissions": { + let policy = WebExtensionPolicy.getByID(data.id); + if (!policy) { + break; + } + // In the parent process, Extension.jsm updates the policy. + if (lazy.isContentProcess) { + lazy.ExtensionCommon.updateAllowedOrigins( + policy, + data.origins, + data.add + ); + + if (data.permissions.length) { + let perms = new Set(policy.permissions); + for (let perm of data.permissions) { + if (data.add) { + perms.add(perm); + } else { + perms.delete(perm); + } + } + policy.permissions = perms; + } + } + + if (data.permissions.length && extensions.has(policy)) { + // Notify ChildApiManager of permission changes. + extensions.get(policy).emit("update-permissions"); + } + break; + } + + case "Extension:UpdateIgnoreQuarantine": { + let policy = WebExtensionPolicy.getByID(data.id); + if (policy?.active) { + policy.ignoreQuarantine = data.ignoreQuarantine; + } + break; + } + } + } catch (e) { + Cu.reportError(e); + } + Services.cpmm.sendAsyncMessage(`${name}Complete`); + }, +}; + +export var ExtensionProcessScript = { + extensions, + + initExtension(extension) { + return ExtensionManager.initExtensionPolicy(extension); + }, + + initExtensionDocument(policy, doc, privileged) { + let extension = extensions.get(policy); + if (privileged) { + lazy.ExtensionPageChild.initExtensionContext(extension, doc.defaultView); + } else { + lazy.ExtensionContent.initExtensionContext(extension, doc.defaultView); + } + }, + + getExtensionChild(id) { + let policy = WebExtensionPolicy.getByID(id); + if (policy) { + return extensions.get(policy); + } + }, + + preloadContentScript(contentScript) { + if (lazy.isContentScriptProcess) { + lazy.ExtensionContent.contentScripts.get(contentScript).preload(); + } + }, + + loadContentScript(contentScript, window) { + return lazy.ExtensionContent.contentScripts + .get(contentScript) + .injectInto(window); + }, +}; + +export var ExtensionAPIRequestHandler = { + initExtensionWorker(policy, serviceWorkerInfo) { + let extension = extensions.get(policy); + + if (!extension) { + throw new Error(`Extension instance not found for addon ${policy.id}`); + } + + lazy.ExtensionWorkerChild.initExtensionWorkerContext( + extension, + serviceWorkerInfo + ); + }, + + onExtensionWorkerLoaded(policy, serviceWorkerDescriptorId) { + lazy.ExtensionWorkerChild.notifyExtensionWorkerContextLoaded( + serviceWorkerDescriptorId, + policy + ); + }, + + onExtensionWorkerDestroyed(policy, serviceWorkerDescriptorId) { + lazy.ExtensionWorkerChild.destroyExtensionWorkerContext( + serviceWorkerDescriptorId + ); + }, + + handleAPIRequest(policy, request) { + let context; + + try { + let extension = extensions.get(policy); + + if (!extension) { + throw new Error(`Extension instance not found for addon ${policy.id}`); + } + + context = this.getExtensionContextForAPIRequest({ + extension, + request, + }); + + if (!context) { + throw new Error( + `Extension context not found for API request: ${request}` + ); + } + + // Add a property to the request object for the normalizedArgs. + request.normalizedArgs = this.validateAndNormalizeRequestArgs({ + context, + request, + }); + + return context.childManager.handleWebIDLAPIRequest(request); + } catch (error) { + // Propagate errors related to parameter validation when the error object + // belongs to the extension context that initiated the call. + if (context?.Error && error instanceof context.Error) { + return { + type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR, + value: error, + }; + } + // Do not propagate errors that are not meant to be accessible to the + // extension, report it to the console and just throw the generic + // "An unexpected error occurred". + Cu.reportError(error); + return { + type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR, + value: new Error("An unexpected error occurred"), + }; + } + }, + + getExtensionContextForAPIRequest({ extension, request }) { + if (request.serviceWorkerInfo) { + return lazy.ExtensionWorkerChild.getExtensionWorkerContext( + extension, + request.serviceWorkerInfo + ); + } + + return null; + }, + + validateAndNormalizeRequestArgs({ context, request }) { + if ( + !lazy.Schemas.checkPermissions(request.apiNamespace, context.extension) + ) { + throw new context.Error( + `Not enough privileges to access ${request.apiNamespace}` + ); + } + if (request.requestType === "getProperty") { + return []; + } + + if (request.apiObjectType) { + // skip parameter validation on request targeting an api object, + // even the JS-based implementation of the API objects are not + // going through the same kind of Schema based validation that + // the API namespaces methods and events go through. + // + // TODO(Bug 1728535): validate and normalize also this request arguments + // as a low priority follow up. + return request.args; + } + + // Validate and normalize parameters, set the normalized args on the + // mozIExtensionAPIRequest normalizedArgs property. + return lazy.Schemas.checkWebIDLRequestParameters( + context.childManager, + request + ); + }, +}; + +ExtensionManager.init(); diff --git a/toolkit/components/extensions/ExtensionScriptingStore.sys.mjs b/toolkit/components/extensions/ExtensionScriptingStore.sys.mjs new file mode 100644 index 0000000000..444af8e41f --- /dev/null +++ b/toolkit/components/extensions/ExtensionScriptingStore.sys.mjs @@ -0,0 +1,351 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +import { ExtensionParent } from "resource://gre/modules/ExtensionParent.sys.mjs"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const { StartupCache } = ExtensionParent; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + KeyValueService: "resource://gre/modules/kvstore.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "matchAboutBlankDefaultFalse", + "extensions.scripting.matchAboutBlankDefaultFalse", + false +); + +class Store { + async _init() { + const { path: storePath } = lazy.FileUtils.getDir("ProfD", [ + "extension-store", + ]); + // Make sure the folder exists. + await IOUtils.makeDirectory(storePath, { ignoreExisting: true }); + this._store = await lazy.KeyValueService.getOrCreate( + storePath, + "scripting-contentScripts" + ); + } + + lazyInit() { + if (!this._initPromise) { + this._initPromise = this._init(); + } + + return this._initPromise; + } + + /** + * Returns all the stored scripts for a given extension (ID). + * + * @param {string} extensionId An extension ID + * @returns {Promise} An array of scripts + */ + async getAll(extensionId) { + await this.lazyInit(); + const pairs = await this.getByExtensionId(extensionId); + + return pairs.map(([_, script]) => script); + } + + /** + * Writes all the scripts provided for a given extension (ID) to the internal + * store (which is eventually stored on disk). + * + * We store each script of an extension as a key/value pair where the key is + * `/` and the value is the corresponding script + * details as a JSON string. + * + * The format on disk should look like this one: + * + * ``` + * { + * "@extension-id/script-1": {"id: "script-1", }, + * "@extension-id/script-2": {"id: "script-2", } + * } + * ``` + * + * @param {string} extensionId An extension ID + * @param {Array} scripts An array of scripts to store on disk + */ + async writeMany(extensionId, scripts) { + await this.lazyInit(); + + return this._store.writeMany( + scripts.map(script => [ + `${extensionId}/${script.id}`, + JSON.stringify(script), + ]) + ); + } + + /** + * Deletes all the stored scripts for a given extension (ID). + * + * @param {string} extensionId An extension ID + */ + async deleteAll(extensionId) { + await this.lazyInit(); + const pairs = await this.getByExtensionId(extensionId); + + return Promise.all(pairs.map(([key, _]) => this._store.delete(key))); + } + + /** + * Returns an array of key/script pairs from the internal store belonging to + * the given extension (ID). + * + * The data returned by this method should look like this (assuming we have + * two scripts named `script-1` and `script-2` for the extension with ID + * `@extension-id`): + * + * ``` + * [ + * ["@extension-id/script-1", {"id: "script-1", }], + * ["@extension-id/script-2", {"id: "script-2", }] + * ] + * ``` + * + * @param {string} extensionId An extension ID + * @returns {Promise} An array of key/script pairs + */ + async getByExtensionId(extensionId) { + await this.lazyInit(); + + const entries = []; + // Retrieve all the scripts registered for the given extension ID by + // enumerating all keys that are stored in a lexical order. + const enumerator = await this._store.enumerate( + `${extensionId}/`, // from_key (inclusive) + `${extensionId}0` // to_key (exclusive) + ); + + while (enumerator.hasMoreElements()) { + const { key, value } = enumerator.getNext(); + entries.push([key, JSON.parse(value)]); + } + + return entries; + } +} + +const store = new Store(); + +/** + * Given an extension and some content script options, this function returns + * the content script representation we use internally, which is an object with + * a `scriptId` and a nested object containing `options`. These (internal) + * objects are shared with all content processes using IPC/sharedData. + * + * This function can optionally prepend the extension's base URL to the CSS and + * JS paths, which is needed when we load internal scripts from the scripting + * store (because the UUID in the base URL changes). + * + * @param {Extension} extension + * The extension that owns the content script. + * @param {object} options + * Content script options. + * @param {boolean} prependBaseURL + * Whether to prepend JS and CSS paths with the extension's base URL. + * + * @returns {object} + */ +export const makeInternalContentScript = ( + extension, + options, + prependBaseURL = false +) => { + let cssPaths = options.css || []; + let jsPaths = options.js || []; + + if (prependBaseURL) { + cssPaths = cssPaths.map(css => `${extension.baseURL}${css}`); + jsPaths = jsPaths.map(js => `${extension.baseURL}${js}`); + } + + return { + scriptId: ExtensionUtils.getUniqueId(), + options: { + // We need to store the user-supplied script ID for persisted scripts. + id: options.id, + allFrames: options.allFrames || false, + // Although this flag defaults to true with MV3, it is not with MV2. + // Check permissions at runtime since we aren't checking permissions + // upfront. + checkPermissions: true, + cssPaths, + excludeMatches: options.excludeMatches, + jsPaths, + // TODO(Bug 1853411): revert the short-term workaround special casing + // webcompat extension id once it is not necessary anymore. + matchAboutBlank: lazy.matchAboutBlankDefaultFalse + ? false // If the hidden pref is set, then forcefully set matchAboutBlank to false + : extension.id !== "webcompat@mozilla.org", + matches: options.matches, + originAttributesPatterns: null, + persistAcrossSessions: options.persistAcrossSessions, + runAt: options.runAt || "document_idle", + }, + }; +}; + +/** + * Given an internal content script registered with the "scripting" API (and an + * extension), this function returns a new object that matches the public + * "scripting" API. + * + * This function is primarily in `scripting.getRegisteredContentScripts()`. + * + * @param {Extension} extension + * The extension that owns the content script. + * @param {object} internalScript + * An internal script (see also: `makeInternalContentScript()`). + * + * @returns {object} + */ +export const makePublicContentScript = (extension, internalScript) => { + let script = { + id: internalScript.id, + allFrames: internalScript.allFrames, + matches: internalScript.matches, + runAt: internalScript.runAt, + persistAcrossSessions: internalScript.persistAcrossSessions, + }; + + if (internalScript.cssPaths.length) { + script.css = internalScript.cssPaths.map(cssPath => + cssPath.replace(extension.baseURL, "") + ); + } + + if (internalScript.excludeMatches?.length) { + script.excludeMatches = internalScript.excludeMatches; + } + + if (internalScript.jsPaths.length) { + script.js = internalScript.jsPaths.map(jsPath => + jsPath.replace(extension.baseURL, "") + ); + } + + return script; +}; + +export const ExtensionScriptingStore = { + async initExtension(extension) { + let scripts; + + // On downgrades/upgrades (and re-installation on top of an existing one), + // we do clear any previously stored scripts and return earlier. + switch (extension.startupReason) { + case "ADDON_INSTALL": + case "ADDON_UPGRADE": + case "ADDON_DOWNGRADE": + // On extension upgrades/downgrades the StartupCache data for the + // extension would already be cleared, and so we set the hasPersistedScripts + // flag here just to avoid having to check that (by loading the rkv store data) + // on the next startup. + StartupCache.general.set( + [extension.id, extension.version, "scripting", "hasPersistedScripts"], + false + ); + store.deleteAll(extension.id); + return; + } + + const hasPersistedScripts = await StartupCache.get( + extension, + ["scripting", "hasPersistedScripts"], + async () => { + scripts = await store.getAll(extension.id); + return !!scripts.length; + } + ); + + if (!hasPersistedScripts) { + return; + } + + // Load the scripts from the storage, then convert them to their internal + // representation and add them to the extension's registered scripts. + scripts ??= await store.getAll(extension.id); + + scripts.forEach(script => { + const { scriptId, options } = makeInternalContentScript( + extension, + script, + true /* prepend the css/js paths with the extension base URL */ + ); + extension.registeredContentScripts.set(scriptId, options); + }); + }, + + getInitialScriptIdsMap(extension) { + // This returns the current map of public script IDs to internal IDs. + // `extension.registeredContentScripts` is initialized in `initExtension`, + // which may be updated later via the scripting API. In practice, the map + // of script IDs is retrieved before any scripting API method is exposed, + // so the return value always matches the initial result from + // `initExtension`. + return new Map( + Array.from(extension.registeredContentScripts.entries()) + .filter( + // Filter out entries without an options.id property, which are the + // ones registered through the contentScripts API namespace where the + // id attribute is not allowed, while it is mandatory for the + // scripting API namespace. + ([_id, options]) => options.id?.length + ) + .map(([scriptId, options]) => [options.id, scriptId]) + ); + }, + + async persistAll(extension) { + // We only persist the scripts that should be persisted and we convert each + // script to their "public" representation before storing them. This is + // because we don't want to deal with data migrations if we ever want to + // change the internal representation (the "public" representation is less + // likely to change because it is bound to the public scripting API). + const scripts = Array.from(extension.registeredContentScripts.values()) + .filter(options => options.persistAcrossSessions) + .map(options => makePublicContentScript(extension, options)); + + // We want to replace all the scripts for the extension so we should delete + // the existing ones first, and then write the new ones. + // + // TODO: Bug 1783131 - Implement individual updates without requiring all + // data to be erased and written. + await store.deleteAll(extension.id); + await store.writeMany(extension.id, scripts); + StartupCache.general.set( + [extension.id, extension.version, "scripting", "hasPersistedScripts"], + !!scripts.length + ); + }, + + // Delete all the persisted scripts for the given extension (id). + // + // NOTE: to be only used on addon uninstall, the extension entry in the StartupCache + // is expected to also be fully cleared as part of handling the addon uninstall. + async clearOnUninstall(extensionId) { + await store.deleteAll(extensionId); + }, + + // As its name implies, don't use this method for anything but an easy access + // to the internal store for testing purposes. + _getStoreForTesting() { + return store; + }, +}; diff --git a/toolkit/components/extensions/ExtensionSettingsStore.sys.mjs b/toolkit/components/extensions/ExtensionSettingsStore.sys.mjs new file mode 100644 index 0000000000..ed139bcf12 --- /dev/null +++ b/toolkit/components/extensions/ExtensionSettingsStore.sys.mjs @@ -0,0 +1,681 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +/** + * @file + * This module is used for storing changes to settings that are + * requested by extensions, and for finding out what the current value + * of a setting should be, based on the precedence chain. + * + * When multiple extensions request to make a change to a particular + * setting, the most recently installed extension will be given + * precedence. + * + * This precedence chain of settings is stored in JSON format, + * without indentation, using UTF-8 encoding. + * With indentation applied, the file would look like this: + * + * { + * type: { // The type of settings being stored in this object, i.e., prefs. + * key: { // The unique key for the setting. + * initialValue, // The initial value of the setting. + * precedenceList: [ + * { + * id, // The id of the extension requesting the setting. + * installDate, // The install date of the extension, stored as a number. + * value, // The value of the setting requested by the extension. + * enabled // Whether the setting is currently enabled. + * } + * ], + * }, + * key: { + * // ... + * } + * } + * } + * + */ + +import { ExtensionParent } from "resource://gre/modules/ExtensionParent.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + JSONFile: "resource://gre/modules/JSONFile.sys.mjs", +}); + +// Defined for readability of precedence and selection code. keyInfo.selected will be +// one of these defines, or the id of an extension if an extension has been explicitly +// selected. +const SETTING_USER_SET = null; +const SETTING_PRECEDENCE_ORDER = undefined; + +const JSON_FILE_NAME = "extension-settings.json"; +const JSON_FILE_VERSION = 3; +const STORE_PATH = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + JSON_FILE_NAME +); + +let _initializePromise; +let _store = {}; + +// Processes the JSON data when read from disk to convert string dates into numbers. +function dataPostProcessor(json) { + if (json.version !== JSON_FILE_VERSION) { + for (let storeType in json) { + for (let setting in json[storeType]) { + for (let extData of json[storeType][setting].precedenceList) { + if (setting == "overrideContentColorScheme" && extData.value > 2) { + extData.value = 2; + } + if (typeof extData.installDate != "number") { + extData.installDate = new Date(extData.installDate).valueOf(); + } + } + } + } + json.version = JSON_FILE_VERSION; + } + return json; +} + +// Loads the data from the JSON file into memory. +function initialize() { + if (!_initializePromise) { + _store = new lazy.JSONFile({ + path: STORE_PATH, + dataPostProcessor, + }); + _initializePromise = _store.load(); + } + return _initializePromise; +} + +// Test-only method to force reloading of the JSON file. +async function reloadFile(saveChanges) { + if (!saveChanges) { + // Disarm the saver so that the current changes are dropped. + _store._saver.disarm(); + } + await _store.finalize(); + _initializePromise = null; + return initialize(); +} + +// Checks that the store is ready and that the requested type exists. +function ensureType(type) { + if (!_store.dataReady) { + throw new Error( + "The ExtensionSettingsStore was accessed before the initialize promise resolved." + ); + } + + // Ensure a property exists for the given type. + if (!_store.data[type]) { + _store.data[type] = {}; + } +} + +/** + * Return an object with properties for key, value|initialValue, id|null, or + * null if no setting has been stored for that key. + * + * If no id is passed then return the highest priority item for the key. + * + * @param {string} type + * The type of setting to be retrieved. + * @param {string} key + * A string that uniquely identifies the setting. + * @param {string} [id] + * The id of the extension for which the item is being retrieved. + * If no id is passed, then the highest priority item for the key + * is returned. + * + * @returns {object | null} + * Either an object with properties for key and value, or + * null if no key is found. + */ +function getItem(type, key, id) { + ensureType(type); + + let keyInfo = _store.data[type][key]; + if (!keyInfo) { + return null; + } + + // If no id was provided, the selected entry will have precedence. + if (!id && keyInfo.selected) { + id = keyInfo.selected; + } + if (id) { + // Return the item that corresponds to the extension with id of id. + let item = keyInfo.precedenceList.find(item => item.id === id); + return item ? { key, value: item.value, id } : null; + } + + // Find the highest precedence, enabled setting, if it has not been + // user set. + if (keyInfo.selected === SETTING_PRECEDENCE_ORDER) { + for (let item of keyInfo.precedenceList) { + if (item.enabled) { + return { key, value: item.value, id: item.id }; + } + } + } + + // Nothing found in the precedenceList or the setting is user-set, + // return the initialValue. + return { key, initialValue: keyInfo.initialValue }; +} + +/** + * Return an array of objects with properties for key, value, id, and enabled + * or an empty array if no settings have been stored for that key. + * + * @param {string} type + * The type of setting to be retrieved. + * @param {string} key + * A string that uniquely identifies the setting. + * + * @returns {Array} an array of objects with properties for key, value, id, and enabled + */ +function getAllItems(type, key) { + ensureType(type); + + let keyInfo = _store.data[type][key]; + if (!keyInfo) { + return []; + } + + let items = keyInfo.precedenceList; + return items + ? items.map(item => ({ + key, + value: item.value, + id: item.id, + enabled: item.enabled, + })) + : []; +} + +// Comparator used when sorting the precedence list. +function precedenceComparator(a, b) { + if (a.enabled && !b.enabled) { + return -1; + } + if (b.enabled && !a.enabled) { + return 1; + } + return b.installDate - a.installDate; +} + +/** + * Helper method that alters a setting, either by changing its enabled status + * or by removing it. + * + * @param {string|null} id + * The id of the extension for which a setting is being altered, may also + * be SETTING_USER_SET (null). + * @param {string} type + * The type of setting to be altered. + * @param {string} key + * A string that uniquely identifies the setting. + * @param {string} action + * The action to perform on the setting. + * Will be one of remove|enable|disable. + * + * @returns {object | null} + * Either an object with properties for key and value, which + * corresponds to the current top precedent setting, or null if + * the current top precedent setting has not changed. + */ +function alterSetting(id, type, key, action) { + let returnItem = null; + ensureType(type); + + let keyInfo = _store.data[type][key]; + if (!keyInfo) { + if (action === "remove") { + return null; + } + throw new Error( + `Cannot alter the setting for ${type}:${key} as it does not exist.` + ); + } + + let foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id); + + if (foundIndex === -1 && (action !== "select" || id !== SETTING_USER_SET)) { + if (action === "remove") { + return null; + } + throw new Error( + `Cannot alter the setting for ${type}:${key} as ${id} does not exist.` + ); + } + + let selected = keyInfo.selected; + switch (action) { + case "select": + if (foundIndex >= 0 && !keyInfo.precedenceList[foundIndex].enabled) { + throw new Error( + `Cannot select the setting for ${type}:${key} as ${id} is disabled.` + ); + } + keyInfo.selected = id; + keyInfo.selectedDate = Date.now(); + break; + + case "remove": + // Removing a user-set setting reverts to precedence order. + if (id === keyInfo.selected) { + keyInfo.selected = SETTING_PRECEDENCE_ORDER; + delete keyInfo.selectedDate; + } + keyInfo.precedenceList.splice(foundIndex, 1); + break; + + case "enable": + keyInfo.precedenceList[foundIndex].enabled = true; + keyInfo.precedenceList.sort(precedenceComparator); + // Enabling a setting does not change a user-set setting, so we + // save and bail early. + if (keyInfo.selected !== SETTING_PRECEDENCE_ORDER) { + _store.saveSoon(); + return null; + } + foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id); + break; + + case "disable": + // Disabling a user-set setting reverts to precedence order. + if (keyInfo.selected === id) { + keyInfo.selected = SETTING_PRECEDENCE_ORDER; + delete keyInfo.selectedDate; + } + keyInfo.precedenceList[foundIndex].enabled = false; + keyInfo.precedenceList.sort(precedenceComparator); + break; + + default: + throw new Error(`${action} is not a valid action for alterSetting.`); + } + + if (selected !== keyInfo.selected || foundIndex === 0) { + returnItem = getItem(type, key); + } + + if (action === "remove" && keyInfo.precedenceList.length === 0) { + delete _store.data[type][key]; + } + + _store.saveSoon(); + ExtensionParent.apiManager.emit("extension-setting-changed", { + action, + id, + type, + key, + item: returnItem, + }); + return returnItem; +} + +export var ExtensionSettingsStore = { + SETTING_USER_SET, + + /** + * Loads the JSON file for the SettingsStore into memory. + * The promise this returns must be resolved before asking the SettingsStore + * to perform any other operations. + * + * @returns {Promise} + * A promise that resolves when the Store is ready to be accessed. + */ + initialize() { + return initialize(); + }, + + /** + * Adds a setting to the store, returning the new setting if it changes. + * + * @param {string} id + * The id of the extension for which a setting is being added. + * @param {string} type + * The type of setting to be stored. + * @param {string} key + * A string that uniquely identifies the setting. + * @param {string} value + * The value to be stored in the setting. + * @param {Function} initialValueCallback + * A function to be called to determine the initial value for the + * setting. This will be passed the value in the callbackArgument + * argument. If omitted the initial value will be undefined. + * @param {any} callbackArgument + * The value to be passed into the initialValueCallback. It defaults to + * the value of the key argument. + * @param {Function} settingDataUpdate + * A function to be called to modify the initial value if necessary. + * + * @returns {Promise} Either an object with properties for key and + * value, which corresponds to the item that was + * just added, or null if the item that was just + * added does not need to be set because it is not + * selected or at the top of the precedence list. + */ + async addSetting( + id, + type, + key, + value, + initialValueCallback = () => undefined, + callbackArgument = key, + settingDataUpdate = val => val + ) { + if (typeof initialValueCallback != "function") { + throw new Error("initialValueCallback must be a function."); + } + + ensureType(type); + + if (!_store.data[type][key]) { + // The setting for this key does not exist. Set the initial value. + let initialValue = await initialValueCallback(callbackArgument); + _store.data[type][key] = { + initialValue, + precedenceList: [], + }; + } + let keyInfo = _store.data[type][key]; + + // Allow settings to upgrade the initial value if necessary. + keyInfo.initialValue = settingDataUpdate(keyInfo.initialValue); + + // Check for this item in the precedenceList. + let foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id); + let newInstall = false; + if (foundIndex === -1) { + // No item for this extension, so add a new one. + let addon = await lazy.AddonManager.getAddonByID(id); + keyInfo.precedenceList.push({ + id, + installDate: addon.installDate.valueOf(), + value, + enabled: true, + }); + newInstall = addon.installDate.valueOf() > keyInfo.selectedDate; + } else { + // Item already exists or this extension, so update it. + let item = keyInfo.precedenceList[foundIndex]; + item.value = value; + // Ensure the item is enabled. + item.enabled = true; + } + + // Sort the list. + keyInfo.precedenceList.sort(precedenceComparator); + foundIndex = keyInfo.precedenceList.findIndex(item => item.id == id); + + // If our new setting is top of precedence, then reset the selected entry. + if (foundIndex === 0 && newInstall) { + keyInfo.selected = SETTING_PRECEDENCE_ORDER; + delete keyInfo.selectedDate; + } + + _store.saveSoon(); + + // Check whether this is currently selected item if one is + // selected, otherwise the top item has precedence. + if ( + keyInfo.selected !== SETTING_USER_SET && + (keyInfo.selected === id || foundIndex === 0) + ) { + return { id, key, value }; + } + return null; + }, + + /** + * Removes a setting from the store, returning the new setting if it changes. + * + * @param {string} id + * The id of the extension for which a setting is being removed. + * @param {string} type + * The type of setting to be removed. + * @param {string} key + * A string that uniquely identifies the setting. + * + * @returns {object | null} + * Either an object with properties for key and value if the setting changes, or null. + */ + removeSetting(id, type, key) { + return alterSetting(id, type, key, "remove"); + }, + + /** + * Enables a setting in the store, returning the new setting if it changes. + * + * @param {string} id + * The id of the extension for which a setting is being enabled. + * @param {string} type + * The type of setting to be enabled. + * @param {string} key + * A string that uniquely identifies the setting. + * + * @returns {object | null} + * Either an object with properties for key and value if the setting changes, or null. + */ + enable(id, type, key) { + return alterSetting(id, type, key, "enable"); + }, + + /** + * Disables a setting in the store, returning the new setting if it changes. + * + * @param {string} id + * The id of the extension for which a setting is being disabled. + * @param {string} type + * The type of setting to be disabled. + * @param {string} key + * A string that uniquely identifies the setting. + * + * @returns {object | null} + * Either an object with properties for key and value if the setting changes, or null. + */ + disable(id, type, key) { + return alterSetting(id, type, key, "disable"); + }, + + /** + * Specifically select an extension, or no extension, that will be in control of + * this setting. + * + * To select a specific extension that controls this setting, pass the extension id. + * + * To select as user-set pass SETTING_USER_SET as the id. In this case, no extension + * will have control of the setting. + * + * Once a specific selection is made, precedence order will not be used again unless the selected + * extension is disabled, removed, or a new extension takes control of the setting. + * + * @param {string | null} id + * The id of the extension being selected or SETTING_USER_SET (null). + * @param {string} type + * The type of setting to be selected. + * @param {string} key + * A string that uniquely identifies the setting. + * + * @returns {object | null} + * Either an object with properties for key and value if the setting changes, or null. + */ + select(id, type, key) { + return alterSetting(id, type, key, "select"); + }, + + /** + * Retrieves all settings from the store for a given extension. + * + * @param {string} id + * The id of the extension for which a settings are being retrieved. + * @param {string} type + * The type of setting to be returned. + * + * @returns {Array} + * A list of settings which have been stored for the extension. + */ + getAllForExtension(id, type) { + ensureType(type); + + let keysObj = _store.data[type]; + let items = []; + for (let key in keysObj) { + if (keysObj[key].precedenceList.find(item => item.id == id)) { + items.push(key); + } + } + return items; + }, + + /** + * Retrieves a setting from the store, either for a specific extension, + * or current top precedent setting for the key. + * + * @param {string} type The type of setting to be returned. + * @param {string} key A string that uniquely identifies the setting. + * @param {string} id + * The id of the extension for which the setting is being retrieved. + * Defaults to undefined, in which case the top setting is returned. + * + * @returns {object} An object with properties for key, value and id. + */ + getSetting(type, key, id) { + return getItem(type, key, id); + }, + + /** + * Retrieves an array of objects representing extensions attempting to control the specified setting + * or an empty array if no settings have been stored for that key. + * + * @param {string} type + * The type of setting to be retrieved. + * @param {string} key + * A string that uniquely identifies the setting. + * + * @returns {Array} an array of objects with properties for key, value, id, and enabled + */ + getAllSettings(type, key) { + return getAllItems(type, key); + }, + + /** + * Returns whether an extension currently has a stored setting for a given + * key. + * + * @param {string} id The id of the extension which is being checked. + * @param {string} type The type of setting to be checked. + * @param {string} key A string that uniquely identifies the setting. + * + * @returns {boolean} Whether the extension currently has a stored setting. + */ + hasSetting(id, type, key) { + return this.getAllForExtension(id, type).includes(key); + }, + + /** + * Return the levelOfControl for a key / extension combo. + * levelOfControl is required by Google's ChromeSetting prototype which + * in turn is used by the privacy API among others. + * + * It informs a caller of the state of a setting with respect to the current + * extension, and can be one of the following values: + * + * controlled_by_other_extensions: controlled by extensions with higher precedence + * controllable_by_this_extension: can be controlled by this extension + * controlled_by_this_extension: controlled by this extension + * + * @param {string} id + * The id of the extension for which levelOfControl is being requested. + * @param {string} type + * The type of setting to be returned. For example `pref`. + * @param {string} key + * A string that uniquely identifies the setting, for example, a + * preference name. + * + * @returns {Promise} + * The level of control of the extension over the key. + */ + async getLevelOfControl(id, type, key) { + ensureType(type); + + let keyInfo = _store.data[type][key]; + if (!keyInfo || !keyInfo.precedenceList.length) { + return "controllable_by_this_extension"; + } + + if (keyInfo.selected !== SETTING_PRECEDENCE_ORDER) { + if (id === keyInfo.selected) { + return "controlled_by_this_extension"; + } + // When user set, the setting is never "controllable" unless the installDate + // is later than the user date. + let addon = await lazy.AddonManager.getAddonByID(id); + return !addon || keyInfo.selectedDate > addon.installDate.valueOf() + ? "not_controllable" + : "controllable_by_this_extension"; + } + + let enabledItems = keyInfo.precedenceList.filter(item => item.enabled); + if (!enabledItems.length) { + return "controllable_by_this_extension"; + } + + let topItem = enabledItems[0]; + if (topItem.id == id) { + return "controlled_by_this_extension"; + } + + let addon = await lazy.AddonManager.getAddonByID(id); + return !addon || topItem.installDate > addon.installDate.valueOf() + ? "controlled_by_other_extensions" + : "controllable_by_this_extension"; + }, + + /** + * Test-only method to force reloading of the JSON file. + * + * Note that this method simply clears the local variable that stores the + * file, so the next time the file is accessed it will be reloaded. + * + * @param {boolean} saveChanges + * When false, discard any changes that have been made since the last + * time the store was saved. + * @returns {Promise} + * A promise that resolves once the settings store has been cleared. + */ + _reloadFile(saveChanges = true) { + return reloadFile(saveChanges); + }, +}; + +// eslint-disable-next-line mozilla/balanced-listeners +ExtensionParent.apiManager.on("uninstall-complete", async (type, { id }) => { + // Catch any settings that were not properly removed during "uninstall". + await ExtensionSettingsStore.initialize(); + for (let type in _store.data) { + // prefs settings must be handled by ExtensionPreferencesManager. + if (type === "prefs") { + continue; + } + let items = ExtensionSettingsStore.getAllForExtension(id, type); + for (let key of items) { + ExtensionSettingsStore.removeSetting(id, type, key); + Services.console.logStringMessage( + `Post-Uninstall removal of addon settings for ${id}, type: ${type} key: ${key}` + ); + } + } +}); diff --git a/toolkit/components/extensions/ExtensionShortcuts.sys.mjs b/toolkit/components/extensions/ExtensionShortcuts.sys.mjs new file mode 100644 index 0000000000..7b30fa2cdf --- /dev/null +++ b/toolkit/components/extensions/ExtensionShortcuts.sys.mjs @@ -0,0 +1,513 @@ +/* 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/. */ + +import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", + ExtensionSettingsStore: + "resource://gre/modules/ExtensionSettingsStore.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs", +}); + +/** + * These properties cannot be lazy getters otherwise they + * get defined on first use, at a time when some modules + * may not have been loaded. In that case, the getter would + * become undefined until next app restart. + */ +Object.defineProperties(lazy, { + windowTracker: { + get() { + return lazy.ExtensionParent.apiManager.global.windowTracker; + }, + }, + browserActionFor: { + get() { + return lazy.ExtensionParent.apiManager.global.browserActionFor; + }, + }, + pageActionFor: { + get() { + return lazy.ExtensionParent.apiManager.global.pageActionFor; + }, + }, + sidebarActionFor: { + get() { + return lazy.ExtensionParent.apiManager.global.sidebarActionFor; + }, + }, +}); + +const { ExtensionError, DefaultMap } = ExtensionUtils; +const { makeWidgetId } = ExtensionCommon; + +const EXECUTE_SIDEBAR_ACTION = "_execute_sidebar_action"; + +function normalizeShortcut(shortcut) { + return shortcut ? shortcut.replace(/\s+/g, "") : ""; +} + +export class ExtensionShortcutKeyMap extends DefaultMap { + async buildForAddonIds(addonIds) { + this.clear(); + for (const addonId of addonIds) { + const policy = WebExtensionPolicy.getByID(addonId); + if (policy?.extension?.shortcuts) { + const { shortcuts } = policy.extension; + for (const command of await shortcuts.allCommands()) { + this.recordShortcut(command.shortcut, policy.name, command.name); + } + } + } + } + + recordShortcut(shortcutString, addonName, commandName) { + if (!shortcutString) { + return; + } + + const valueSet = this.get(shortcutString); + valueSet.add({ addonName, commandName }); + } + + removeShortcut(shortcutString, addonName, commandName) { + if (!this.has(shortcutString)) { + return; + } + + const valueSet = this.get(shortcutString); + for (const entry of valueSet.values()) { + if (entry.addonName === addonName && entry.commandName === commandName) { + valueSet.delete(entry); + } + } + if (valueSet.size === 0) { + this.delete(shortcutString); + } + } + + getFirstAddonName(shortcutString) { + if (this.has(shortcutString)) { + return this.get(shortcutString).values().next().value.addonName; + } + return null; + } + + has(shortcutString) { + const platformShortcut = this.getPlatformShortcutString(shortcutString); + return super.has(platformShortcut) && super.get(platformShortcut).size > 0; + } + + // Class internals. + + constructor() { + super(() => new Set()); + + // Overridden in some unit test to make it easier to cover some + // platform specific behaviors (in particular the platform specific. + // normalization of the shortcuts using the Ctrl modifier on macOS). + this._os = lazy.ExtensionParent.PlatformInfo.os; + } + + getPlatformShortcutString(shortcutString) { + if (this._os == "mac") { + // when running on macos, make sure to also track in the shortcutKeyMap + // (which is used to check for duplicated shortcuts) a shortcut string + // that replace the `Ctrl` modifiers with the `Command` modified: + // they are going to be the same accel in the key element generated, + // by tracking both of them shortcut string value would confuse the about:addons "Manager Shortcuts" + // view and make it unable to correctly catch conflicts on mac + // (See bug 1565854). + shortcutString = shortcutString + .split("+") + .map(p => (p === "Ctrl" ? "Command" : p)) + .join("+"); + } + + return shortcutString; + } + + get(shortcutString) { + const platformShortcut = this.getPlatformShortcutString(shortcutString); + return super.get(platformShortcut); + } + + add(shortcutString, addonCommandValue) { + const setValue = this.get(shortcutString); + setValue.add(addonCommandValue); + } + + delete(shortcutString) { + const platformShortcut = this.getPlatformShortcutString(shortcutString); + return super.delete(platformShortcut); + } +} + +/** + * An instance of this class is assigned to the shortcuts property of each + * active webextension that has commands defined. + * + * It manages loading any updated shortcuts along with the ones defined in + * the manifest and registering them to a browser window. It also provides + * the list, update and reset APIs for the browser.commands interface and + * the about:addons manage shortcuts page. + */ +export class ExtensionShortcuts { + static async removeCommandsFromStorage(extensionId) { + // Cleanup the updated commands. In some cases the extension is installed + // and uninstalled so quickly that `this.commands` hasn't loaded yet. To + // handle that we need to make sure ExtensionSettingsStore is initialized + // before we clean it up. + await lazy.ExtensionSettingsStore.initialize(); + lazy.ExtensionSettingsStore.getAllForExtension( + extensionId, + "commands" + ).forEach(key => { + lazy.ExtensionSettingsStore.removeSetting(extensionId, "commands", key); + }); + } + + constructor({ extension, onCommand, onShortcutChanged }) { + this.keysetsMap = new WeakMap(); + this.windowOpenListener = null; + this.extension = extension; + this.onCommand = onCommand; + this.onShortcutChanged = onShortcutChanged; + this.id = makeWidgetId(extension.id); + } + + async allCommands() { + let commands = await this.commands; + return Array.from(commands, ([name, command]) => { + return { + name, + description: command.description, + shortcut: command.shortcut, + }; + }); + } + + async updateCommand({ name, description, shortcut }) { + let { extension } = this; + let commands = await this.commands; + let command = commands.get(name); + + if (!command) { + throw new ExtensionError(`Unknown command "${name}"`); + } + + // Only store the updates so manifest changes can take precedence + // later. + let previousUpdates = await lazy.ExtensionSettingsStore.getSetting( + "commands", + name, + extension.id + ); + let commandUpdates = (previousUpdates && previousUpdates.value) || {}; + + if (description && description != command.description) { + commandUpdates.description = description; + command.description = description; + } + + let oldShortcut = command.shortcut; + + if (shortcut != null && shortcut != command.shortcut) { + shortcut = normalizeShortcut(shortcut); + commandUpdates.shortcut = shortcut; + command.shortcut = shortcut; + } + + await lazy.ExtensionSettingsStore.addSetting( + extension.id, + "commands", + name, + commandUpdates + ); + + this.registerKeys(commands); + + if (command.shortcut !== oldShortcut) { + this.onShortcutChanged({ + name, + newShortcut: command.shortcut, + oldShortcut, + }); + } + } + + async resetCommand(name) { + let { extension, manifestCommands } = this; + let commands = await this.commands; + let command = commands.get(name); + + if (!command) { + throw new ExtensionError(`Unknown command "${name}"`); + } + + let storedCommand = lazy.ExtensionSettingsStore.getSetting( + "commands", + name, + extension.id + ); + + if (storedCommand && storedCommand.value) { + commands.set(name, { ...manifestCommands.get(name) }); + lazy.ExtensionSettingsStore.removeSetting(extension.id, "commands", name); + this.registerKeys(commands); + } + } + + loadCommands() { + let { extension } = this; + + // Map[{String} commandName -> {Object} commandProperties] + this.manifestCommands = this.loadCommandsFromManifest(extension.manifest); + + this.commands = (async () => { + // Deep copy the manifest commands to commands so we can keep the original + // manifest commands and update commands as needed. + let commands = new Map(); + this.manifestCommands.forEach((command, name) => { + commands.set(name, { ...command }); + }); + + // Update the manifest commands with the persisted updates from + // browser.commands.update(). + let savedCommands = await this.loadCommandsFromStorage(extension.id); + savedCommands.forEach((update, name) => { + let command = commands.get(name); + if (command) { + // We will only update commands, not add them. + Object.assign(command, update); + } + }); + + return commands; + })(); + } + + registerKeys(commands) { + for (let window of lazy.windowTracker.browserWindows()) { + this.registerKeysToDocument(window, commands); + } + } + + /** + * Registers the commands to all open windows and to any which + * are later created. + */ + async register() { + let commands = await this.commands; + this.registerKeys(commands); + + this.windowOpenListener = window => { + if (!this.keysetsMap.has(window)) { + this.registerKeysToDocument(window, commands); + } + }; + + lazy.windowTracker.addOpenListener(this.windowOpenListener); + } + + /** + * Unregisters the commands from all open windows and stops commands + * from being registered to windows which are later created. + */ + unregister() { + for (let window of lazy.windowTracker.browserWindows()) { + if (this.keysetsMap.has(window)) { + this.keysetsMap.get(window).remove(); + } + } + + lazy.windowTracker.removeOpenListener(this.windowOpenListener); + } + + /** + * Creates a Map from commands for each command in the manifest.commands object. + * + * @param {object} manifest The manifest JSON object. + * @returns {Map} + */ + loadCommandsFromManifest(manifest) { + let commands = new Map(); + // For Windows, chrome.runtime expects 'win' while chrome.commands + // expects 'windows'. We can special case this for now. + let { PlatformInfo } = lazy.ExtensionParent; + let os = PlatformInfo.os == "win" ? "windows" : PlatformInfo.os; + for (let [name, command] of Object.entries(manifest.commands)) { + let suggested_key = command.suggested_key || {}; + let shortcut = normalizeShortcut( + suggested_key[os] || suggested_key.default + ); + commands.set(name, { + description: command.description, + shortcut, + }); + } + return commands; + } + + async loadCommandsFromStorage(extensionId) { + await lazy.ExtensionSettingsStore.initialize(); + let names = lazy.ExtensionSettingsStore.getAllForExtension( + extensionId, + "commands" + ); + return names.reduce((map, name) => { + let command = lazy.ExtensionSettingsStore.getSetting( + "commands", + name, + extensionId + ).value; + return map.set(name, command); + }, new Map()); + } + + /** + * Registers the commands to a document. + * + * @param {ChromeWindow} window The XUL window to insert the Keyset. + * @param {Map} commands The commands to be set. + */ + registerKeysToDocument(window, commands) { + if ( + !this.extension.privateBrowsingAllowed && + lazy.PrivateBrowsingUtils.isWindowPrivate(window) + ) { + return; + } + + let doc = window.document; + let keyset = doc.createXULElement("keyset"); + keyset.id = `ext-keyset-id-${this.id}`; + if (this.keysetsMap.has(window)) { + this.keysetsMap.get(window).remove(); + } + let sidebarKey; + for (let [name, command] of commands) { + if (command.shortcut) { + let parts = command.shortcut.split("+"); + + // The key is always the last element. + let key = parts.pop(); + + if (/^[0-9]$/.test(key)) { + let shortcutWithNumpad = command.shortcut.replace( + /[0-9]$/, + "Numpad$&" + ); + let numpadKeyElement = this.buildKey(doc, name, shortcutWithNumpad); + keyset.appendChild(numpadKeyElement); + } + + let keyElement = this.buildKey(doc, name, command.shortcut); + keyset.appendChild(keyElement); + if (name == EXECUTE_SIDEBAR_ACTION) { + sidebarKey = keyElement; + } + } + } + doc.documentElement.appendChild(keyset); + if (sidebarKey) { + window.SidebarUI.updateShortcut({ keyId: sidebarKey.id }); + } + this.keysetsMap.set(window, keyset); + } + + /** + * Builds a XUL Key element and attaches an onCommand listener which + * emits a command event with the provided name when fired. + * + * @param {Document} doc The XUL document. + * @param {string} name The name of the command. + * @param {string} shortcut The shortcut provided in the manifest. + * @see https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/key + * + * @returns {Element} The newly created Key element. + */ + buildKey(doc, name, shortcut) { + let keyElement = this.buildKeyFromShortcut(doc, name, shortcut); + + // We need to have the attribute "oncommand" for the "command" listener to fire, + // and it is currently ignored when set to the empty string. + keyElement.setAttribute("oncommand", "//"); + + /* eslint-disable mozilla/balanced-listeners */ + // We remove all references to the key elements when the extension is shutdown, + // therefore the listeners for these elements will be garbage collected. + keyElement.addEventListener("command", event => { + let action; + let _execute_action = + this.extension.manifestVersion < 3 + ? "_execute_browser_action" + : "_execute_action"; + + let actionFor = { + [_execute_action]: lazy.browserActionFor, + _execute_page_action: lazy.pageActionFor, + _execute_sidebar_action: lazy.sidebarActionFor, + }[name]; + + if (actionFor) { + action = actionFor(this.extension); + let win = event.target.ownerGlobal; + action.triggerAction(win); + } else { + this.extension.tabManager.addActiveTabPermission(); + this.onCommand(name); + } + }); + /* eslint-enable mozilla/balanced-listeners */ + + return keyElement; + } + + /** + * Builds a XUL Key element from the provided shortcut. + * + * @param {Document} doc The XUL document. + * @param {string} name The name of the shortcut. + * @param {string} shortcut The shortcut provided in the manifest. + * + * @see https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/key + * @returns {Element} The newly created Key element. + */ + buildKeyFromShortcut(doc, name, shortcut) { + let keyElement = doc.createXULElement("key"); + + let parts = shortcut.split("+"); + + // The key is always the last element. + let chromeKey = parts.pop(); + + // The modifiers are the remaining elements. + keyElement.setAttribute( + "modifiers", + lazy.ShortcutUtils.getModifiersAttribute(parts) + ); + + // A keyElement with key "NumpadX" is created above and isn't from the + // manifest. The id will be set on the keyElement with key "X" only. + if (name == EXECUTE_SIDEBAR_ACTION && !chromeKey.startsWith("Numpad")) { + let id = `ext-key-id-${this.id}-sidebar-action`; + keyElement.setAttribute("id", id); + } + + let [attribute, value] = lazy.ShortcutUtils.getKeyAttribute(chromeKey); + keyElement.setAttribute(attribute, value); + if (attribute == "keycode") { + keyElement.setAttribute("event", "keydown"); + } + + return keyElement; + } +} diff --git a/toolkit/components/extensions/ExtensionStorage.sys.mjs b/toolkit/components/extensions/ExtensionStorage.sys.mjs new file mode 100644 index 0000000000..4155fbaa24 --- /dev/null +++ b/toolkit/components/extensions/ExtensionStorage.sys.mjs @@ -0,0 +1,573 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { DefaultWeakMap, ExtensionError } = ExtensionUtils; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", + JSONFile: "resource://gre/modules/JSONFile.sys.mjs", +}); + +function isStructuredCloneHolder(value) { + return ( + value && + typeof value === "object" && + Cu.getClassName(value, true) === "StructuredCloneHolder" + ); +} + +class SerializeableMap extends Map { + toJSON() { + let result = {}; + for (let [key, value] of this) { + if (isStructuredCloneHolder(value)) { + value = value.deserialize(globalThis); + this.set(key, value); + } + + result[key] = value; + } + return result; + } + + /** + * Like toJSON, but attempts to serialize every value separately, and + * elides any which fail to serialize. Should only be used if initial + * JSON serialization fails. + * + * @returns {object} + */ + toJSONSafe() { + let result = {}; + for (let [key, value] of this) { + try { + void JSON.stringify(value); + + result[key] = value; + } catch (e) { + Cu.reportError( + new Error(`Failed to serialize browser.storage key "${key}": ${e}`) + ); + } + } + return result; + } +} + +/** + * Serializes an arbitrary value into a StructuredCloneHolder, if + * appropriate. Existing StructuredCloneHolders are returned unchanged. + * Non-object values are also returned unchanged. Anything else is + * serialized, and a new StructuredCloneHolder returned. + * + * This allows us to avoid a second structured clone operation after + * sending a storage value across a message manager, before cloning it + * into an extension scope. + * + * @param {string} name + * A debugging name for the value, which will appear in the + * StructuredCloneHolder's about:memory path. + * @param {string?} anonymizedName + * An anonymized version of `name`, to be used in anonymized memory + * reports. If `null`, then `name` will be used instead. + * @param {StructuredCloneHolder|*} value + * A value to serialize. + * @returns {*} + */ +function serialize(name, anonymizedName, value) { + if (value && typeof value === "object" && !isStructuredCloneHolder(value)) { + return new StructuredCloneHolder(name, anonymizedName, value); + } + return value; +} + +export var ExtensionStorage = { + // Map> + jsonFilePromises: new Map(), + + listeners: new Map(), + + /** + * Asynchronously reads the storage file for the given extension ID + * and returns a Promise for its initialized JSONFile object. + * + * @param {string} extensionId + * The ID of the extension for which to return a file. + * @returns {Promise>} + */ + async _readFile(extensionId) { + await IOUtils.makeDirectory(this.getExtensionDir(extensionId)); + + let jsonFile = new lazy.JSONFile({ + path: this.getStorageFile(extensionId), + }); + await jsonFile.load(); + + jsonFile.data = this._serializableMap(jsonFile.data); + return jsonFile; + }, + + _serializableMap(data) { + return new SerializeableMap(Object.entries(data)); + }, + + /** + * Returns a Promise for initialized JSONFile instance for the + * extension's storage file. + * + * @param {string} extensionId + * The ID of the extension for which to return a file. + * @returns {Promise>} + */ + getFile(extensionId) { + let promise = this.jsonFilePromises.get(extensionId); + if (!promise) { + promise = this._readFile(extensionId); + this.jsonFilePromises.set(extensionId, promise); + } + return promise; + }, + + /** + * Clear the cached jsonFilePromise for a given extensionId + * (used by ExtensionStorageIDB to free the jsonFile once the data migration + * has been completed). + * + * @param {string} extensionId + * The ID of the extension for which to return a file. + */ + async clearCachedFile(extensionId) { + let promise = this.jsonFilePromises.get(extensionId); + if (promise) { + this.jsonFilePromises.delete(extensionId); + await promise.then(jsonFile => jsonFile.finalize()); + } + }, + + /** + * Sanitizes the given value, and returns a JSON-compatible + * representation of it, based on the privileges of the given global. + * + * @param {any} value + * The value to sanitize. + * @param {Context} context + * The extension context in which to sanitize the value + * @returns {value} + * The sanitized value. + */ + sanitize(value, context) { + let json = context.jsonStringify(value === undefined ? null : value); + if (json == undefined) { + throw new ExtensionError( + "DataCloneError: The object could not be cloned." + ); + } + return JSON.parse(json); + }, + + /** + * Returns the path to the storage directory within the profile for + * the given extension ID. + * + * @param {string} extensionId + * The ID of the extension for which to return a directory path. + * @returns {string} + */ + getExtensionDir(extensionId) { + return PathUtils.join(this.extensionDir, extensionId); + }, + + /** + * Returns the path to the JSON storage file for the given extension + * ID. + * + * @param {string} extensionId + * The ID of the extension for which to return a file path. + * @returns {string} + */ + getStorageFile(extensionId) { + return PathUtils.join(this.extensionDir, extensionId, "storage.js"); + }, + + /** + * Asynchronously sets the values of the given storage items for the + * given extension. + * + * @param {string} extensionId + * The ID of the extension for which to set storage values. + * @param {object} items + * The storage items to set. For each property in the object, + * the storage value for that property is set to its value in + * said object. Any values which are StructuredCloneHolder + * instances are deserialized before being stored. + * @returns {Promise} + */ + async set(extensionId, items) { + let jsonFile = await this.getFile(extensionId); + + let changes = {}; + for (let prop in items) { + let item = items[prop]; + changes[prop] = { + oldValue: serialize( + `set/${extensionId}/old/${prop}`, + `set/${extensionId}/old/`, + jsonFile.data.get(prop) + ), + newValue: serialize( + `set/${extensionId}/new/${prop}`, + `set/${extensionId}/new/`, + item + ), + }; + jsonFile.data.set(prop, item); + } + + this.notifyListeners(extensionId, changes); + + jsonFile.saveSoon(); + return null; + }, + + /** + * Asynchronously removes the given storage items for the given + * extension ID. + * + * @param {string} extensionId + * The ID of the extension for which to remove storage values. + * @param {Array} items + * A list of storage items to remove. + * @returns {Promise} + */ + async remove(extensionId, items) { + let jsonFile = await this.getFile(extensionId); + + let changed = false; + let changes = {}; + + for (let prop of [].concat(items)) { + if (jsonFile.data.has(prop)) { + changes[prop] = { + oldValue: serialize( + `remove/${extensionId}/${prop}`, + `remove/${extensionId}/`, + jsonFile.data.get(prop) + ), + }; + jsonFile.data.delete(prop); + changed = true; + } + } + + if (changed) { + this.notifyListeners(extensionId, changes); + jsonFile.saveSoon(); + } + return null; + }, + + /** + * Asynchronously clears all storage entries for the given extension + * ID. + * + * @param {string} extensionId + * The ID of the extension for which to clear storage. + * @param {object} options + * @param {boolean} [options.shouldNotifyListeners = true] + * Whether or not collect and send the changes to the listeners, + * used when the extension data is being cleared on uninstall. + * @returns {Promise} + */ + async clear(extensionId, { shouldNotifyListeners = true } = {}) { + let jsonFile = await this.getFile(extensionId); + + let changed = false; + let changes = {}; + + for (let [prop, oldValue] of jsonFile.data.entries()) { + if (shouldNotifyListeners) { + changes[prop] = { + oldValue: serialize( + `clear/${extensionId}/${prop}`, + `clear/${extensionId}/`, + oldValue + ), + }; + } + + jsonFile.data.delete(prop); + changed = true; + } + + if (changed) { + if (shouldNotifyListeners) { + this.notifyListeners(extensionId, changes); + } + + jsonFile.saveSoon(); + } + return null; + }, + + /** + * Asynchronously retrieves the values for the given storage items for + * the given extension ID. + * + * @param {string} extensionId + * The ID of the extension for which to get storage values. + * @param {Array|object|null} [keys] + * The storage items to get. If an array, the value of each key + * in the array is returned. If null, the values of all items + * are returned. If an object, the value for each key in the + * object is returned, or that key's value if the item is not + * set. + * @returns {Promise} + * An object which a property for each requested key, + * containing that key's storage value. Values are + * StructuredCloneHolder objects which can be deserialized to + * the original storage value. + */ + async get(extensionId, keys) { + let jsonFile = await this.getFile(extensionId); + return this._filterProperties(extensionId, jsonFile.data, keys); + }, + + async _filterProperties(extensionId, data, keys) { + let result = {}; + if (keys === null) { + Object.assign(result, data.toJSON()); + } else if (typeof keys == "object" && !Array.isArray(keys)) { + for (let prop in keys) { + if (data.has(prop)) { + result[prop] = serialize( + `filterProperties/${extensionId}/${prop}`, + `filterProperties/${extensionId}/`, + data.get(prop) + ); + } else { + result[prop] = keys[prop]; + } + } + } else { + for (let prop of [].concat(keys)) { + if (data.has(prop)) { + result[prop] = serialize( + `filterProperties/${extensionId}/${prop}`, + `filterProperties/${extensionId}/`, + data.get(prop) + ); + } + } + } + + return result; + }, + + addOnChangedListener(extensionId, listener) { + let listeners = this.listeners.get(extensionId) || new Set(); + listeners.add(listener); + this.listeners.set(extensionId, listeners); + }, + + removeOnChangedListener(extensionId, listener) { + let listeners = this.listeners.get(extensionId); + listeners.delete(listener); + }, + + notifyListeners(extensionId, changes) { + let listeners = this.listeners.get(extensionId); + if (listeners) { + for (let listener of listeners) { + listener(changes); + } + } + }, + + init() { + if (Services.appinfo.processType != Services.appinfo.PROCESS_TYPE_DEFAULT) { + return; + } + Services.obs.addObserver(this, "extension-invalidate-storage-cache"); + Services.obs.addObserver(this, "xpcom-shutdown"); + }, + + observe(subject, topic, data) { + if (topic == "xpcom-shutdown") { + Services.obs.removeObserver(this, "extension-invalidate-storage-cache"); + Services.obs.removeObserver(this, "xpcom-shutdown"); + } else if (topic == "extension-invalidate-storage-cache") { + for (let promise of this.jsonFilePromises.values()) { + promise.then(jsonFile => { + jsonFile.finalize(); + }); + } + this.jsonFilePromises.clear(); + } + }, + + // Serializes an arbitrary value into a StructuredCloneHolder, if appropriate. + serialize, + + /** + * Serializes the given storage items for transporting between processes. + * + * @param {BaseContext} context + * The context to use for the created StructuredCloneHolder + * objects. + * @param {Array|object} items + * The items to serialize. If an object is provided, its + * values are serialized to StructuredCloneHolder objects. + * Otherwise, it is returned as-is. + * @returns {Array|object} + */ + serializeForContext(context, items) { + if (items && typeof items === "object" && !Array.isArray(items)) { + let result = {}; + for (let [key, value] of Object.entries(items)) { + try { + result[key] = new StructuredCloneHolder( + `serializeForContext/${context.extension.id}`, + null, + value, + context.cloneScope + ); + } catch (e) { + throw new ExtensionError(String(e)); + } + } + return result; + } + return items; + }, + + /** + * Deserializes the given storage items into the given extension context. + * + * @param {BaseContext} context + * The context to use to deserialize the StructuredCloneHolder objects. + * @param {object} items + * The items to deserialize. Any property of the object which + * is a StructuredCloneHolder instance is deserialized into + * the extension scope. Any other object is cloned into the + * extension scope directly. + * @returns {object} + */ + deserializeForContext(context, items) { + let result = new context.cloneScope.Object(); + for (let [key, value] of Object.entries(items)) { + if ( + value && + typeof value === "object" && + Cu.getClassName(value, true) === "StructuredCloneHolder" + ) { + value = value.deserialize(context.cloneScope, true); + } else { + value = Cu.cloneInto(value, context.cloneScope); + } + result[key] = value; + } + return result; + }, +}; + +ChromeUtils.defineLazyGetter(ExtensionStorage, "extensionDir", () => + PathUtils.join(PathUtils.profileDir, "browser-extension-data") +); + +ExtensionStorage.init(); + +export var extensionStorageSession = { + /** @type {WeakMap>} */ + buckets: new DefaultWeakMap(_extension => new Map()), + + /** @type {WeakMap>} */ + listeners: new DefaultWeakMap(_extension => new Set()), + + /** + * @param {Extension} extension + * @param {null | undefined | string | string[] | object} items + * Schema normalization ensures items are normalized to one of above types. + */ + get(extension, items) { + let bucket = this.buckets.get(extension); + + let result = {}; + /** @type {Iterable} */ + let keys = []; + + if (!items) { + keys = bucket.keys(); + } else if (typeof items !== "object" || Array.isArray(items)) { + keys = [].concat(items); + } else { + keys = Object.keys(items); + result = items; + } + + for (let prop of keys) { + if (bucket.has(prop)) { + result[prop] = bucket.get(prop); + } + } + return result; + }, + + set(extension, items) { + let bucket = this.buckets.get(extension); + + let changes = {}; + for (let [key, value] of Object.entries(items)) { + changes[key] = { + oldValue: bucket.get(key), + newValue: value, + }; + bucket.set(key, value); + } + this.notifyListeners(extension, changes); + }, + + remove(extension, keys) { + let bucket = this.buckets.get(extension); + let changes = {}; + for (let k of [].concat(keys)) { + if (bucket.has(k)) { + changes[k] = { oldValue: bucket.get(k) }; + bucket.delete(k); + } + } + this.notifyListeners(extension, changes); + }, + + clear(extension) { + let bucket = this.buckets.get(extension); + let changes = {}; + for (let k of bucket.keys()) { + changes[k] = { oldValue: bucket.get(k) }; + } + bucket.clear(); + this.notifyListeners(extension, changes); + }, + + registerListener(extension, listener) { + this.listeners.get(extension).add(listener); + return () => { + this.listeners.get(extension).delete(listener); + }; + }, + + notifyListeners(extension, changes) { + if (!Object.keys(changes).length) { + return; + } + for (let listener of this.listeners.get(extension)) { + lazy.ExtensionCommon.runSafeSyncWithoutClone(listener, changes); + } + }, +}; diff --git a/toolkit/components/extensions/ExtensionStorageIDB.sys.mjs b/toolkit/components/extensions/ExtensionStorageIDB.sys.mjs new file mode 100644 index 0000000000..26df3eacdb --- /dev/null +++ b/toolkit/components/extensions/ExtensionStorageIDB.sys.mjs @@ -0,0 +1,878 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { IndexedDB } from "resource://gre/modules/IndexedDB.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionStorage: "resource://gre/modules/ExtensionStorage.sys.mjs", + ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs", + getTrimmedString: "resource://gre/modules/ExtensionTelemetry.sys.mjs", +}); + +// The userContextID reserved for the extension storage (its purpose is ensuring that the IndexedDB +// storage used by the browser.storage.local API is not directly accessible from the extension code, +// it is defined and reserved as "userContextIdInternal.webextStorageLocal" in ContextualIdentityService.sys.mjs). +const WEBEXT_STORAGE_USER_CONTEXT_ID = -1 >>> 0; + +const IDB_NAME = "webExtensions-storage-local"; +const IDB_DATA_STORENAME = "storage-local-data"; +const IDB_VERSION = 1; +const IDB_MIGRATE_RESULT_HISTOGRAM = + "WEBEXT_STORAGE_LOCAL_IDB_MIGRATE_RESULT_COUNT"; + +// Whether or not the installed extensions should be migrated to the storage.local IndexedDB backend. +const BACKEND_ENABLED_PREF = + "extensions.webextensions.ExtensionStorageIDB.enabled"; +const IDB_MIGRATED_PREF_BRANCH = + "extensions.webextensions.ExtensionStorageIDB.migrated"; + +class DataMigrationAbortedError extends Error { + get name() { + return "DataMigrationAbortedError"; + } +} + +var ErrorsTelemetry = { + initialized: false, + + lazyInit() { + if (this.initialized) { + return; + } + this.initialized = true; + + // Ensure that these telemetry events category is enabled. + Services.telemetry.setEventRecordingEnabled("extensions.data", true); + + this.resultHistogram = Services.telemetry.getHistogramById( + IDB_MIGRATE_RESULT_HISTOGRAM + ); + }, + + /** + * Get the DOMException error name for a given error object. + * + * @param {Error | undefined} error + * The Error object to convert into a string, or undefined if there was no error. + * + * @returns {string | undefined} + * The DOMException error name (sliced to a maximum of 80 chars), + * "OtherError" if the error object is not a DOMException instance, + * or `undefined` if there wasn't an error. + */ + getErrorName(error) { + if (!error) { + return undefined; + } + + if ( + DOMException.isInstance(error) || + error instanceof DataMigrationAbortedError + ) { + if (error.name.length > 80) { + return lazy.getTrimmedString(error.name); + } + + return error.name; + } + + return "OtherError"; + }, + + /** + * Record telemetry related to a data migration result. + * + * @param {object} telemetryData + * @param {string} telemetryData.backend + * The backend selected ("JSONFile" or "IndexedDB"). + * @param {boolean} [telemetryData.dataMigrated] + * Old extension data has been migrated successfully. + * @param {string} telemetryData.extensionId + * The id of the extension migrated. + * @param {Error | undefined} telemetryData.error + * The error raised during the data migration, if any. + * @param {boolean} [telemetryData.hasJSONFile] + * The extension has an existing JSONFile to migrate. + * @param {boolean} [telemetryData.hasOldData] + * The extension's JSONFile wasn't empty. + * @param {string} telemetryData.histogramCategory + * The histogram category for the result ("success" or "failure"). + */ + recordDataMigrationResult(telemetryData) { + try { + const { + backend, + dataMigrated, + extensionId, + error, + hasJSONFile, + hasOldData, + histogramCategory, + } = telemetryData; + + this.lazyInit(); + this.resultHistogram.add(histogramCategory); + + const extra = { backend }; + + if (dataMigrated != null) { + extra.data_migrated = dataMigrated ? "y" : "n"; + } + + if (hasJSONFile != null) { + extra.has_jsonfile = hasJSONFile ? "y" : "n"; + } + + if (hasOldData != null) { + extra.has_olddata = hasOldData ? "y" : "n"; + } + + if (error) { + extra.error_name = this.getErrorName(error); + } + + let addon_id = lazy.getTrimmedString(extensionId); + Services.telemetry.recordEvent( + "extensions.data", + "migrateResult", + "storageLocal", + addon_id, + extra + ); + Glean.extensionsData.migrateResult.record({ + addon_id, + backend: extra.backend, + data_migrated: extra.data_migrated, + has_jsonfile: extra.has_jsonfile, + has_olddata: extra.has_olddata, + error_name: extra.error_name, + }); + } catch (err) { + // Report any telemetry error on the browser console, but + // we treat it as a non-fatal error and we don't re-throw + // it to the caller. + Cu.reportError(err); + } + }, + + /** + * Record telemetry related to the unexpected errors raised while executing + * a storage.local API call. + * + * @param {object} options + * @param {string} options.extensionId + * The id of the extension migrated. + * @param {string} options.storageMethod + * The storage.local API method being run. + * @param {Error} options.error + * The unexpected error raised during the API call. + */ + recordStorageLocalError({ extensionId, storageMethod, error }) { + this.lazyInit(); + let addon_id = lazy.getTrimmedString(extensionId); + let error_name = this.getErrorName(error); + + Services.telemetry.recordEvent( + "extensions.data", + "storageLocalError", + storageMethod, + addon_id, + { error_name } + ); + Glean.extensionsData.storageLocalError.record({ + addon_id, + method: storageMethod, + error_name, + }); + }, +}; + +class ExtensionStorageLocalIDB extends IndexedDB { + onupgradeneeded(event) { + if (event.oldVersion < 1) { + this.createObjectStore(IDB_DATA_STORENAME); + } + } + + static openForPrincipal(storagePrincipal) { + // The db is opened using an extension principal isolated in a reserved user context id. + return super.openForPrincipal(storagePrincipal, IDB_NAME, IDB_VERSION); + } + + async isEmpty() { + const cursor = await this.objectStore( + IDB_DATA_STORENAME, + "readonly" + ).openKeyCursor(); + return cursor.done; + } + + /** + * Asynchronously sets the values of the given storage items. + * + * @param {object} items + * The storage items to set. For each property in the object, + * the storage value for that property is set to its value in + * said object. Any values which are StructuredCloneHolder + * instances are deserialized before being stored. + * @param {object} options + * @param {callback} [options.serialize] + * Set to a function which will be used to serialize the values into + * a StructuredCloneHolder object (if appropriate) and being sent + * across the processes (it is also used to detect data cloning errors + * and raise an appropriate error to the caller). + * + * @returns {Promise} + * Return a promise which resolves to the computed "changes" object + * or null. + */ + async set(items, { serialize } = {}) { + const changes = {}; + let changed = false; + + // Explicitly create a transaction, so that we can explicitly abort it + // as soon as one of the put requests fails. + const transaction = this.transaction(IDB_DATA_STORENAME, "readwrite"); + const objectStore = transaction.objectStore(IDB_DATA_STORENAME); + const transactionCompleted = transaction.promiseComplete(); + + if (!serialize) { + serialize = (name, anonymizedName, value) => value; + } + + for (let key of Object.keys(items)) { + try { + let oldValue = await objectStore.get(key); + + await objectStore.put(items[key], key); + + changes[key] = { + oldValue: + oldValue && serialize(`old/${key}`, `old/`, oldValue), + newValue: serialize(`new/${key}`, `new/`, items[key]), + }; + changed = true; + } catch (err) { + transactionCompleted.catch(err => { + // We ignore this rejection because we are explicitly aborting the transaction, + // the transaction.error will be null, and we throw the original error below. + }); + transaction.abort(); + + throw err; + } + } + + await transactionCompleted; + + return changed ? changes : null; + } + + /** + * Asynchronously retrieves the values for the given storage items. + * + * @param {Array|object|null} [keysOrItems] + * The storage items to get. If an array, the value of each key + * in the array is returned. If null, the values of all items + * are returned. If an object, the value for each key in the + * object is returned, or that key's value if the item is not + * set. + * @returns {Promise} + * An object which has a property for each requested key, + * containing that key's value as stored in the IndexedDB + * storage. + */ + async get(keysOrItems) { + let keys; + let defaultValues; + + if (typeof keysOrItems === "string") { + keys = [keysOrItems]; + } else if (Array.isArray(keysOrItems)) { + keys = keysOrItems; + } else if (keysOrItems && typeof keysOrItems === "object") { + keys = Object.keys(keysOrItems); + defaultValues = keysOrItems; + } + + const result = {}; + + // Retrieve all the stored data using a cursor when browser.storage.local.get() + // has been called with no keys. + if (keys == null) { + const cursor = await this.objectStore( + IDB_DATA_STORENAME, + "readonly" + ).openCursor(); + while (!cursor.done) { + result[cursor.key] = cursor.value; + await cursor.continue(); + } + } else { + const objectStore = this.objectStore(IDB_DATA_STORENAME); + for (let key of keys) { + const storedValue = await objectStore.get(key); + if (storedValue === undefined) { + if (defaultValues && defaultValues[key] !== undefined) { + result[key] = defaultValues[key]; + } + } else { + result[key] = storedValue; + } + } + } + + return result; + } + + /** + * Asynchronously removes the given storage items. + * + * @param {string|Array} keys + * A string key of a list of storage items keys to remove. + * @returns {Promise} + * Returns an object which contains applied changes. + */ + async remove(keys) { + // Ensure that keys is an array of strings. + keys = [].concat(keys); + + if (keys.length === 0) { + // Early exit if there is nothing to remove. + return null; + } + + const changes = {}; + let changed = false; + + const objectStore = this.objectStore(IDB_DATA_STORENAME, "readwrite"); + + let promises = []; + + for (let key of keys) { + promises.push( + objectStore.getKey(key).then(async foundKey => { + if (foundKey === key) { + changed = true; + changes[key] = { oldValue: await objectStore.get(key) }; + return objectStore.delete(key); + } + }) + ); + } + + await Promise.all(promises); + + return changed ? changes : null; + } + + /** + * Asynchronously clears all storage entries. + * + * @returns {Promise} + * Returns an object which contains applied changes. + */ + async clear() { + const changes = {}; + let changed = false; + + const objectStore = this.objectStore(IDB_DATA_STORENAME, "readwrite"); + + const cursor = await objectStore.openCursor(); + while (!cursor.done) { + changes[cursor.key] = { oldValue: cursor.value }; + changed = true; + await cursor.continue(); + } + + await objectStore.clear(); + + return changed ? changes : null; + } +} + +/** + * Migrate the data stored in the JSONFile backend to the IDB Backend. + * + * Returns a promise which is resolved once the data migration has been + * completed and the new IDB backend can be enabled. + * Rejects if the data has been read successfully from the JSONFile backend + * but it failed to be saved in the new IDB backend. + * + * This method is called only from the main process (where the file + * can be opened). + * + * @param {Extension} extension + * The extension to migrate to the new IDB backend. + * @param {nsIPrincipal} storagePrincipal + * The "internally reserved" extension storagePrincipal to be used to create + * the ExtensionStorageLocalIDB instance. + */ +async function migrateJSONFileData(extension, storagePrincipal) { + let oldStoragePath; + let oldStorageExists; + let idbConn; + let jsonFile; + let hasEmptyIDB; + let nonFatalError; + let dataMigrateCompleted = false; + let hasOldData = false; + + function abortIfShuttingDown() { + if (extension.hasShutdown || Services.startup.shuttingDown) { + throw new DataMigrationAbortedError("extension or app is shutting down"); + } + } + + if (ExtensionStorageIDB.isMigratedExtension(extension)) { + return; + } + + try { + abortIfShuttingDown(); + idbConn = await ExtensionStorageIDB.open( + storagePrincipal, + extension.hasPermission("unlimitedStorage") + ); + abortIfShuttingDown(); + + hasEmptyIDB = await idbConn.isEmpty(); + + if (!hasEmptyIDB) { + // If the IDB backend is enabled and there is data already stored in the IDB backend, + // there is no "going back": any data that has not been migrated will be still on disk + // but it is not going to be migrated anymore, it could be eventually used to allow + // a user to manually retrieve the old data file). + ExtensionStorageIDB.setMigratedExtensionPref(extension, true); + return; + } + } catch (err) { + extension.logWarning( + `storage.local data migration cancelled, unable to open IDB connection: ${err.message}::${err.stack}` + ); + + ErrorsTelemetry.recordDataMigrationResult({ + backend: "JSONFile", + extensionId: extension.id, + error: err, + histogramCategory: "failure", + }); + + throw err; + } + + try { + abortIfShuttingDown(); + + oldStoragePath = lazy.ExtensionStorage.getStorageFile(extension.id); + oldStorageExists = await IOUtils.exists(oldStoragePath).catch(fileErr => { + // If we can't access the oldStoragePath here, then extension is also going to be unable to + // access it, and so we log the error but we don't stop the extension from switching to + // the IndexedDB backend. + extension.logWarning( + `Unable to access extension storage.local data file: ${fileErr.message}::${fileErr.stack}` + ); + return false; + }); + + // Migrate any data stored in the JSONFile backend (if any), and remove the old data file + // if the migration has been completed successfully. + if (oldStorageExists) { + // Do not load the old JSON file content if shutting down is already in progress. + abortIfShuttingDown(); + + Services.console.logStringMessage( + `Migrating storage.local data for ${extension.policy.debugName}...` + ); + + jsonFile = await lazy.ExtensionStorage.getFile(extension.id); + + abortIfShuttingDown(); + + const data = {}; + for (let [key, value] of jsonFile.data.entries()) { + data[key] = value; + hasOldData = true; + } + + await idbConn.set(data); + Services.console.logStringMessage( + `storage.local data successfully migrated to IDB Backend for ${extension.policy.debugName}.` + ); + } + + dataMigrateCompleted = true; + } catch (err) { + extension.logWarning( + `Error on migrating storage.local data file: ${err.message}::${err.stack}` + ); + + if (oldStorageExists && !dataMigrateCompleted) { + ErrorsTelemetry.recordDataMigrationResult({ + backend: "JSONFile", + dataMigrated: dataMigrateCompleted, + extensionId: extension.id, + error: err, + hasJSONFile: oldStorageExists, + hasOldData, + histogramCategory: "failure", + }); + + // If the data failed to be stored into the IndexedDB backend, then we clear the IndexedDB + // backend to allow the extension to retry the migration on its next startup, and reject + // the data migration promise explicitly (which would prevent the new backend + // from being enabled for this session). + await new Promise(resolve => { + let req = Services.qms.clearStoragesForPrincipal(storagePrincipal); + req.callback = resolve; + }); + + throw err; + } + + // This error is not preventing the extension from switching to the IndexedDB backend, + // but we may still want to know that it has been triggered and include it into the + // telemetry data collected for the extension. + nonFatalError = err; + } finally { + // Clear the jsonFilePromise cached by the ExtensionStorage. + await lazy.ExtensionStorage.clearCachedFile(extension.id).catch(err => { + extension.logWarning(err.message); + }); + } + + // If the IDB backend has been enabled, rename the old storage.local data file, but + // do not prevent the extension from switching to the IndexedDB backend if it fails. + if (oldStorageExists && dataMigrateCompleted) { + try { + // Only migrate the file when it actually exists (e.g. the file name is not going to exist + // when it is corrupted, because JSONFile internally rename it to `.corrupt`. + if (await IOUtils.exists(oldStoragePath)) { + const uniquePath = await IOUtils.createUniqueFile( + PathUtils.parent(oldStoragePath), + `${PathUtils.filename(oldStoragePath)}.migrated` + ); + await IOUtils.move(oldStoragePath, uniquePath); + } + } catch (err) { + nonFatalError = err; + extension.logWarning(err.message); + } + } + + ExtensionStorageIDB.setMigratedExtensionPref(extension, true); + + ErrorsTelemetry.recordDataMigrationResult({ + backend: "IndexedDB", + dataMigrated: dataMigrateCompleted, + extensionId: extension.id, + error: nonFatalError, + hasJSONFile: oldStorageExists, + hasOldData, + histogramCategory: "success", + }); +} + +/** + * This ExtensionStorage class implements a backend for the storage.local API which + * uses IndexedDB to store the data. + */ +export var ExtensionStorageIDB = { + BACKEND_ENABLED_PREF, + IDB_MIGRATED_PREF_BRANCH, + IDB_MIGRATE_RESULT_HISTOGRAM, + + // Map> + listeners: new Map(), + + // Keep track if the IDB backend has been selected or not for a running extension + // (the selected backend should never change while the extension is running, even if the + // related preference has been changed in the meantime): + // + // WeakMap Promise + selectedBackendPromises: new WeakMap(), + + init() { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "isBackendEnabled", + BACKEND_ENABLED_PREF, + false + ); + }, + + isMigratedExtension(extension) { + return Services.prefs.getBoolPref( + `${IDB_MIGRATED_PREF_BRANCH}.${extension.id}`, + false + ); + }, + + setMigratedExtensionPref(extension, val) { + Services.prefs.setBoolPref( + `${IDB_MIGRATED_PREF_BRANCH}.${extension.id}`, + !!val + ); + }, + + clearMigratedExtensionPref(extensionId) { + Services.prefs.clearUserPref(`${IDB_MIGRATED_PREF_BRANCH}.${extensionId}`); + }, + + getStoragePrincipal(extension) { + return extension.createPrincipal(extension.baseURI, { + userContextId: WEBEXT_STORAGE_USER_CONTEXT_ID, + }); + }, + + /** + * Select the preferred backend and return a promise which is resolved once the + * selected backend is ready to be used (e.g. if the extension is switching from + * the old JSONFile storage to the new IDB backend, any previously stored data will + * be migrated to the backend before the promise is resolved). + * + * This method is called from both the main and child (content or extension) processes: + * - an extension child context will call this method lazily, when the browser.storage.local + * is being used for the first time, and it will result into asking the main process + * to call the same method in the main process + * - on the main process side, it will check if the new IDB backend can be used (and if it can, + * it will migrate any existing data into the new backend, which needs to happen in the + * main process where the file can directly be accessed) + * + * The result will be cached while the extension is still running, and so an extension + * child context is going to ask the main process only once per child process, and on the + * main process side the backend selection and data migration will happen only once. + * + * @param {import("ExtensionPageChild.sys.mjs").ExtensionBaseContextChild} context + * The extension context that is selecting the storage backend. + * + * @returns {Promise} + * Returns a promise which resolves to an object which provides a + * `backendEnabled` boolean property, and if it is true the extension should use + * the IDB backend and the object also includes a `storagePrincipal` property + * of type nsIPrincipal, otherwise `backendEnabled` will be false when the + * extension should use the old JSONFile backend (e.g. because the IDB backend has + * not been enabled from the preference). + */ + selectBackend(context) { + const { extension } = context; + + if (!this.selectedBackendPromises.has(extension)) { + let promise; + + if (context.childManager) { + return context.childManager + .callParentAsyncFunction("storage.local.IDBBackend.selectBackend", []) + .then(parentResult => { + let result; + + if (!parentResult.backendEnabled) { + result = { backendEnabled: false }; + } else { + result = { + ...parentResult, + // In the child process, we need to deserialize the storagePrincipal + // from the StructuredCloneHolder used to send it across the processes. + storagePrincipal: parentResult.storagePrincipal.deserialize( + this, + true + ), + }; + } + + // Cache the result once we know that it has been resolved. The promise returned by + // context.childManager.callParentAsyncFunction will be dead when context.cloneScope + // is destroyed. To keep a promise alive in the cache, we wrap the result in an + // independent promise. + this.selectedBackendPromises.set( + extension, + Promise.resolve(result) + ); + + return result; + }); + } + + // If migrating to the IDB backend is not enabled by the preference, then we + // don't need to migrate any data and the new backend is not enabled. + if (!this.isBackendEnabled) { + promise = Promise.resolve({ backendEnabled: false }); + } else { + // In the main process, lazily create a storagePrincipal isolated in a + // reserved user context id (its purpose is ensuring that the IndexedDB storage used + // by the browser.storage.local API is not directly accessible from the extension code). + const storagePrincipal = this.getStoragePrincipal(extension); + + // Serialize the nsIPrincipal object into a StructuredCloneHolder related to the privileged + // js global, ready to be sent to the child processes. + const serializedPrincipal = new StructuredCloneHolder( + "ExtensionStorageIDB/selectBackend/serializedPrincipal", + null, + storagePrincipal, + this + ); + + promise = migrateJSONFileData(extension, storagePrincipal) + .then(() => { + extension.setSharedData("storageIDBBackend", true); + extension.setSharedData("storageIDBPrincipal", storagePrincipal); + Services.ppmm.sharedData.flush(); + return { + backendEnabled: true, + storagePrincipal: serializedPrincipal, + }; + }) + .catch(err => { + // If the data migration promise is rejected, the old data has been read + // successfully from the old JSONFile backend but it failed to be saved + // into the IndexedDB backend (which is likely unrelated to the kind of + // data stored and more likely a general issue with the IndexedDB backend) + // In this case we keep the JSONFile backend enabled for this session + // and we will retry to migrate to the IDB Backend the next time the + // extension is being started. + // TODO Bug 1465129: This should be a very unlikely scenario, some telemetry + // data about it may be useful. + extension.logWarning( + "JSONFile backend is being kept enabled by an unexpected " + + `IDBBackend failure: ${err.message}::${err.stack}` + ); + extension.setSharedData("storageIDBBackend", false); + Services.ppmm.sharedData.flush(); + + return { backendEnabled: false }; + }); + } + + this.selectedBackendPromises.set(extension, promise); + } + + return this.selectedBackendPromises.get(extension); + }, + + persist(storagePrincipal) { + return new Promise((resolve, reject) => { + const request = Services.qms.persist(storagePrincipal); + request.callback = () => { + if (request.resultCode === Cr.NS_OK) { + resolve(); + } else { + reject( + new Error( + `Failed to persist storage for principal: ${storagePrincipal.originNoSuffix}` + ) + ); + } + }; + }); + }, + + /** + * Open a connection to the IDB storage.local db for a given extension. + * given extension. + * + * @param {nsIPrincipal} storagePrincipal + * The "internally reserved" extension storagePrincipal to be used to create + * the ExtensionStorageLocalIDB instance. + * @param {boolean} persisted + * A boolean which indicates if the storage should be set into persistent mode. + * + * @returns {Promise} + * Return a promise which resolves to the opened IDB connection. + */ + open(storagePrincipal, persisted) { + if (!storagePrincipal) { + return Promise.reject(new Error("Unexpected empty principal")); + } + let setPersistentMode = persisted + ? this.persist(storagePrincipal) + : Promise.resolve(); + return setPersistentMode.then(() => + ExtensionStorageLocalIDB.openForPrincipal(storagePrincipal) + ); + }, + + /** + * Ensure that an error originated from the ExtensionStorageIDB methods is normalized + * into an ExtensionError (e.g. DataCloneError and QuotaExceededError instances raised + * from the internal IndexedDB operations have to be converted into an ExtensionError + * to be accessible to the extension code). + * + * @param {object} params + * @param {Error|ExtensionError|DOMException} params.error + * The error object to normalize. + * @param {string} params.extensionId + * The id of the extension that was executing the storage.local method. + * @param {string} params.storageMethod + * The storage method being executed when the error has been thrown + * (used to keep track of the unexpected error incidence in telemetry). + * + * @returns {ExtensionError} + * Return an ExtensionError error instance. + */ + normalizeStorageError({ error, extensionId, storageMethod }) { + const { ExtensionError } = lazy.ExtensionUtils; + + if (error instanceof ExtensionError) { + // @ts-ignore (will go away after `lazy` is properly typed) + return error; + } + + let errorMessage; + + if (DOMException.isInstance(error)) { + switch (error.name) { + case "DataCloneError": + errorMessage = String(error); + break; + case "QuotaExceededError": + errorMessage = `${error.name}: storage.local API call exceeded its quota limitations.`; + break; + } + } + + if (!errorMessage) { + Cu.reportError(error); + + errorMessage = "An unexpected error occurred"; + + ErrorsTelemetry.recordStorageLocalError({ + error, + extensionId, + storageMethod, + }); + } + + return new ExtensionError(errorMessage); + }, + + addOnChangedListener(extensionId, listener) { + let listeners = this.listeners.get(extensionId) || new Set(); + listeners.add(listener); + this.listeners.set(extensionId, listeners); + }, + + removeOnChangedListener(extensionId, listener) { + let listeners = this.listeners.get(extensionId); + listeners.delete(listener); + }, + + notifyListeners(extensionId, changes) { + let listeners = this.listeners.get(extensionId); + if (listeners) { + for (let listener of listeners) { + listener(changes); + } + } + }, + + hasListeners(extensionId) { + let listeners = this.listeners.get(extensionId); + return listeners && listeners.size > 0; + }, +}; + +ExtensionStorageIDB.init(); diff --git a/toolkit/components/extensions/ExtensionStorageSync.sys.mjs b/toolkit/components/extensions/ExtensionStorageSync.sys.mjs new file mode 100644 index 0000000000..d41cf5af12 --- /dev/null +++ b/toolkit/components/extensions/ExtensionStorageSync.sys.mjs @@ -0,0 +1,201 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +const STORAGE_SYNC_ENABLED_PREF = "webextensions.storage.sync.enabled"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const NS_ERROR_DOM_QUOTA_EXCEEDED_ERR = 0x80530016; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", + ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs", + // We might end up falling back to kinto... + extensionStorageSyncKinto: + "resource://gre/modules/ExtensionStorageSyncKinto.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "prefPermitsStorageSync", + STORAGE_SYNC_ENABLED_PREF, + true +); + +// This xpcom service implements a "bridge" from the JS world to the Rust world. +// It sets up the database and implements a callback-based version of the +// browser.storage API. +ChromeUtils.defineLazyGetter(lazy, "storageSvc", () => + Cc["@mozilla.org/extensions/storage/sync;1"] + .getService(Ci.nsIInterfaceRequestor) + .getInterface(Ci.mozIExtensionStorageArea) +); + +// The interfaces which define the callbacks used by the bridge. There's a +// callback for success, failure, and to record data changes. +function ExtensionStorageApiCallback(resolve, reject, changeCallback) { + this.resolve = resolve; + this.reject = reject; + this.changeCallback = changeCallback; +} + +ExtensionStorageApiCallback.prototype = { + QueryInterface: ChromeUtils.generateQI([ + "mozIExtensionStorageListener", + "mozIExtensionStorageCallback", + ]), + + handleSuccess(result) { + this.resolve(result ? JSON.parse(result) : null); + }, + + handleError(code, message) { + let e = new Error(message); + e.code = code; + Cu.reportError(e); + this.reject(e); + }, + + onChanged(extId, json) { + if (this.changeCallback && json) { + try { + this.changeCallback(extId, JSON.parse(json)); + } catch (ex) { + Cu.reportError(ex); + } + } + }, +}; + +// The backing implementation of the browser.storage.sync web extension API. +export class ExtensionStorageSync { + constructor() { + this.listeners = new Map(); + // We are optimistic :) If we ever see the special nsresult which indicates + // migration failure, it will become false. In practice, this will only ever + // happen on the first operation. + this.migrationOk = true; + } + + // The main entry-point to our bridge. It performs some important roles: + // * Ensures the API is allowed to be used. + // * Works out what "extension id" to use. + // * Turns the callback API into a promise API. + async _promisify(fnName, extension, context, ...args) { + let extId = extension.id; + if (lazy.prefPermitsStorageSync !== true) { + throw new lazy.ExtensionUtils.ExtensionError( + `Please set ${STORAGE_SYNC_ENABLED_PREF} to true in about:config` + ); + } + + if (this.migrationOk) { + // We can call ours. + try { + return await new Promise((resolve, reject) => { + let callback = new ExtensionStorageApiCallback( + resolve, + reject, + (extId, changes) => this.notifyListeners(extId, changes) + ); + let sargs = args.map(val => JSON.stringify(val)); + lazy.storageSvc[fnName](extId, ...sargs, callback); + }); + } catch (ex) { + if (ex.code != Cr.NS_ERROR_CANNOT_CONVERT_DATA) { + // Some non-migration related error we want to sanitize and propagate. + // The only "public" exception here is for quota failure - all others + // are sanitized. + let sanitized = + ex.code == NS_ERROR_DOM_QUOTA_EXCEEDED_ERR + ? // The same message as the local IDB implementation + `QuotaExceededError: storage.sync API call exceeded its quota limitations.` + : // The standard, generic extension error. + "An unexpected error occurred"; + throw new lazy.ExtensionUtils.ExtensionError(sanitized); + } + // This means "migrate failed" so we must fall back to kinto. + Cu.reportError( + "migration of extension-storage failed - will fall back to kinto" + ); + this.migrationOk = false; + } + } + // We've detected failure to migrate, so we want to use kinto. + return lazy.extensionStorageSyncKinto[fnName](extension, ...args, context); + } + + set(extension, items, context) { + return this._promisify("set", extension, context, items); + } + + remove(extension, keys, context) { + return this._promisify("remove", extension, context, keys); + } + + clear(extension, context) { + return this._promisify("clear", extension, context); + } + + clearOnUninstall(extensionId) { + if (!this.migrationOk) { + // If the rust-based backend isn't being used, + // no need to clear it. + return; + } + // Resolve the returned promise once the request has been either resolved + // or rejected (and report the error on the browser console in case of + // unexpected clear failures on addon uninstall). + return new Promise(resolve => { + const callback = new ExtensionStorageApiCallback( + resolve, + err => { + Cu.reportError(err); + resolve(); + }, + // empty changeCallback (no need to notify the extension + // while clearing the extension on uninstall). + () => {} + ); + lazy.storageSvc.clear(extensionId, callback); + }); + } + + get(extension, spec, context) { + return this._promisify("get", extension, context, spec); + } + + getBytesInUse(extension, keys, context) { + return this._promisify("getBytesInUse", extension, context, keys); + } + + addOnChangedListener(extension, listener, context) { + let listeners = this.listeners.get(extension.id) || new Set(); + listeners.add(listener); + this.listeners.set(extension.id, listeners); + } + + removeOnChangedListener(extension, listener) { + let listeners = this.listeners.get(extension.id); + listeners.delete(listener); + if (listeners.size == 0) { + this.listeners.delete(extension.id); + } + } + + notifyListeners(extId, changes) { + let listeners = this.listeners.get(extId) || new Set(); + if (listeners) { + for (let listener of listeners) { + lazy.ExtensionCommon.runSafeSyncWithoutClone(listener, changes); + } + } + } +} + +export var extensionStorageSync = new ExtensionStorageSync(); diff --git a/toolkit/components/extensions/ExtensionStorageSyncKinto.sys.mjs b/toolkit/components/extensions/ExtensionStorageSyncKinto.sys.mjs new file mode 100644 index 0000000000..d10b140f7e --- /dev/null +++ b/toolkit/components/extensions/ExtensionStorageSyncKinto.sys.mjs @@ -0,0 +1,1386 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +// TODO: +// * find out how the Chrome implementation deals with conflicts + +// TODO bug 1637465: Remove the Kinto-based storage implementation. + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const KINTO_PROD_SERVER_URL = + "https://webextensions.settings.services.mozilla.com/v1"; +const KINTO_DEFAULT_SERVER_URL = KINTO_PROD_SERVER_URL; + +const STORAGE_SYNC_ENABLED_PREF = "webextensions.storage.sync.enabled"; +const STORAGE_SYNC_SERVER_URL_PREF = "webextensions.storage.sync.serverURL"; +const STORAGE_SYNC_SCOPE = "sync:addon_storage"; +const STORAGE_SYNC_CRYPTO_COLLECTION_NAME = "storage-sync-crypto"; +const STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID = "keys"; +const STORAGE_SYNC_CRYPTO_SALT_LENGTH_BYTES = 32; +const FXA_OAUTH_OPTIONS = { + scope: STORAGE_SYNC_SCOPE, +}; +// Default is 5sec, which seems a bit aggressive on the open internet +const KINTO_REQUEST_TIMEOUT = 30000; + +import { Log } from "resource://gre/modules/Log.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + BulkKeyBundle: "resource://services-sync/keys.sys.mjs", + CollectionKeyManager: "resource://services-sync/record.sys.mjs", + CommonUtils: "resource://services-common/utils.sys.mjs", + CryptoUtils: "resource://services-crypto/utils.sys.mjs", + ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", + FirefoxAdapter: "resource://services-common/kinto-storage-adapter.sys.mjs", + KintoHttpClient: "resource://services-common/kinto-http-client.sys.mjs", + Observers: "resource://services-common/observers.sys.mjs", + Utils: "resource://services-sync/util.sys.mjs", +}); + +/** + * @typedef {any} Collection + * @typedef {any} CollectionKeyManager + * @typedef {any} FXAccounts + * @typedef {any} KeyBundle + * @typedef {any} SyncResultObject + */ +XPCOMUtils.defineLazyModuleGetters(lazy, { + Kinto: "resource://services-common/kinto-offline-client.js", +}); + +ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => { + return ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ).getFxAccountsSingleton(); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "prefPermitsStorageSync", + STORAGE_SYNC_ENABLED_PREF, + true +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "prefStorageSyncServerURL", + STORAGE_SYNC_SERVER_URL_PREF, + KINTO_DEFAULT_SERVER_URL +); +ChromeUtils.defineLazyGetter(lazy, "WeaveCrypto", function () { + let { WeaveCrypto } = ChromeUtils.importESModule( + "resource://services-crypto/WeaveCrypto.sys.mjs" + ); + return new WeaveCrypto(); +}); + +const { DefaultMap } = ExtensionUtils; + +// Map of Extensions to Set to track contexts that are still +// "live" and use storage.sync. +const extensionContexts = new DefaultMap(() => new Set()); +// Borrow logger from Sync. +const log = Log.repository.getLogger("Sync.Engine.Extension-Storage"); + +// A global that is fxAccounts, or null if (as on android) fxAccounts +// isn't available. +let _fxaService = null; +if (AppConstants.platform != "android") { + _fxaService = lazy.fxAccounts; +} + +class ServerKeyringDeleted extends Error { + constructor() { + super( + "server keyring appears to have disappeared; we were called to decrypt null" + ); + } +} + +/** + * Check for FXA and throw an exception if we don't have access. + * + * @param {object} fxAccounts The reference we were hoping to use to + * access FxA + * @param {string} action The thing we were doing when we decided to + * see if we had access to FxA + */ +function throwIfNoFxA(fxAccounts, action) { + if (!fxAccounts) { + throw new Error( + `${action} is impossible because FXAccounts is not available; are you on Android?` + ); + } +} + +// Global ExtensionStorageSyncKinto instance that extensions and Fx Sync use. +// On Android, because there's no FXAccounts instance, any syncing +// operations will fail. +export var extensionStorageSyncKinto = null; + +/** + * Utility function to enforce an order of fields when computing an HMAC. + * + * @param {KeyBundle} keyBundle The key bundle to use to compute the HMAC + * @param {string} id The record ID to use when computing the HMAC + * @param {string} IV The IV to use when computing the HMAC + * @param {string} ciphertext The ciphertext over which to compute the HMAC + * @returns {Promise} The computed HMAC + */ +async function ciphertextHMAC(keyBundle, id, IV, ciphertext) { + const hmacKey = lazy.CommonUtils.byteStringToArrayBuffer(keyBundle.hmacKey); + const encoder = new TextEncoder(); + const data = encoder.encode(id + IV + ciphertext); + const hmac = await lazy.CryptoUtils.hmac("SHA-256", hmacKey, data); + return lazy.CommonUtils.bytesAsHex( + lazy.CommonUtils.arrayBufferToByteString(hmac) + ); +} + +/** + * Get the current user's hashed kB. + * + * @param {FXAccounts} fxaService The service to use to get the + * current user. + * @returns {Promise} sha256 of the user's kB as a hex string + */ +const getKBHash = async function (fxaService) { + const key = await fxaService.keys.getKeyForScope(STORAGE_SYNC_SCOPE); + return fxaService.keys.kidAsHex(key); +}; + +/** + * A "remote transformer" that the Kinto library will use to + * encrypt/decrypt records when syncing. + * + * This is an "abstract base class". Subclass this and override + * getKeys() to use it. + */ +class EncryptionRemoteTransformer { + async encode(record) { + const keyBundle = await this.getKeys(); + if (record.ciphertext) { + throw new Error("Attempt to reencrypt??"); + } + let id = await this.getEncodedRecordId(record); + if (!id) { + throw new Error("Record ID is missing or invalid"); + } + + let IV = lazy.WeaveCrypto.generateRandomIV(); + let ciphertext = await lazy.WeaveCrypto.encrypt( + JSON.stringify(record), + keyBundle.encryptionKeyB64, + IV + ); + let hmac = await ciphertextHMAC(keyBundle, id, IV, ciphertext); + const encryptedResult = { ciphertext, IV, hmac, id }; + + // Copy over the _status field, so that we handle concurrency + // headers (If-Match, If-None-Match) correctly. + // DON'T copy over "deleted" status, because then we'd leak + // plaintext deletes. + encryptedResult._status = + record._status == "deleted" ? "updated" : record._status; + if (record.hasOwnProperty("last_modified")) { + encryptedResult.last_modified = record.last_modified; + } + + return encryptedResult; + } + + async decode(record) { + if (!record.ciphertext) { + // This can happen for tombstones if a record is deleted. + if (record.deleted) { + return record; + } + throw new Error("No ciphertext: nothing to decrypt?"); + } + const keyBundle = await this.getKeys(); + // Authenticate the encrypted blob with the expected HMAC + let computedHMAC = await ciphertextHMAC( + keyBundle, + record.id, + record.IV, + record.ciphertext + ); + + if (computedHMAC != record.hmac) { + lazy.Utils.throwHMACMismatch(record.hmac, computedHMAC); + } + + // Handle invalid data here. Elsewhere we assume that cleartext is an object. + let cleartext = await lazy.WeaveCrypto.decrypt( + record.ciphertext, + keyBundle.encryptionKeyB64, + record.IV + ); + let jsonResult = JSON.parse(cleartext); + if (!jsonResult || typeof jsonResult !== "object") { + throw new Error( + "Decryption failed: result is <" + jsonResult + ">, not an object." + ); + } + + if (record.hasOwnProperty("last_modified")) { + jsonResult.last_modified = record.last_modified; + } + + // _status: deleted records were deleted on a client, but + // uploaded as an encrypted blob so we don't leak deletions. + // If we get such a record, flag it as deleted. + if (jsonResult._status == "deleted") { + jsonResult.deleted = true; + } + + return jsonResult; + } + + /** + * Retrieve keys to use during encryption. + * + * @returns {Promise} + */ + getKeys() { + throw new Error("override getKeys in a subclass"); + } + + /** + * Compute the record ID to use for the encoded version of the + * record. + * + * The default version just re-uses the record's ID. + * + * @param {object} record The record being encoded. + * @returns {Promise} The ID to use. + */ + getEncodedRecordId(record) { + return Promise.resolve(record.id); + } +} + +/** + * An EncryptionRemoteTransformer that provides a keybundle derived + * from the user's kB, suitable for encrypting a keyring. + */ +class KeyRingEncryptionRemoteTransformer extends EncryptionRemoteTransformer { + constructor(fxaService) { + super(); + this._fxaService = fxaService; + } + + getKeys() { + throwIfNoFxA(this._fxaService, "encrypting chrome.storage.sync records"); + const self = this; + return (async function () { + let key = await self._fxaService.keys.getKeyForScope(STORAGE_SYNC_SCOPE); + return lazy.BulkKeyBundle.fromJWK(key); + })(); + } + // Pass through the kbHash field from the unencrypted record. If + // encryption fails, we can use this to try to detect whether we are + // being compromised or if the record here was encoded with a + // different kB. + async encode(record) { + const encoded = await super.encode(record); + encoded.kbHash = record.kbHash; + return encoded; + } + + async decode(record) { + try { + return await super.decode(record); + } catch (e) { + if (lazy.Utils.isHMACMismatch(e)) { + const currentKBHash = await getKBHash(this._fxaService); + if (record.kbHash != currentKBHash) { + // Some other client encoded this with a kB that we don't + // have access to. + KeyRingEncryptionRemoteTransformer.throwOutdatedKB( + currentKBHash, + record.kbHash + ); + } + } + throw e; + } + } + + // Generator and discriminator for KB-is-outdated exceptions. + static throwOutdatedKB(shouldBe, is) { + throw new Error( + `kB hash on record is outdated: should be ${shouldBe}, is ${is}` + ); + } + + static isOutdatedKB(exc) { + const kbMessage = "kB hash on record is outdated: "; + return ( + exc && + exc.message && + exc.message.indexOf && + exc.message.indexOf(kbMessage) == 0 + ); + } +} + +/** + * A Promise that centralizes initialization of ExtensionStorageSyncKinto. + * + * This centralizes the use of the Sqlite database, to which there is + * only one connection which is shared by all threads. + * + * Fields in the object returned by this Promise: + * + * - connection: a Sqlite connection. Meant for internal use only. + * - kinto: a KintoBase object, suitable for using in Firefox. All + * collections in this database will use the same Sqlite connection. + * + * @returns {Promise} + */ +async function storageSyncInit() { + // Memoize the result to share the connection. + if (storageSyncInit.promise === undefined) { + const path = "storage-sync.sqlite"; + storageSyncInit.promise = lazy.FirefoxAdapter.openConnection({ path }) + .then(connection => { + return { + connection, + kinto: new lazy.Kinto({ + adapter: lazy.FirefoxAdapter, + adapterOptions: { sqliteHandle: connection }, + timeout: KINTO_REQUEST_TIMEOUT, + retry: 0, + }), + }; + }) + .catch(e => { + // Ensure one failure doesn't break us forever. + Cu.reportError(e); + storageSyncInit.promise = undefined; + throw e; + }); + } + return storageSyncInit.promise; +} +storageSyncInit.promise = undefined; + +// Kinto record IDs have two conditions: +// +// - They must contain only ASCII alphanumerics plus - and _. To fix +// this, we encode all non-letters using _C_, where C is the +// percent-encoded character, so space becomes _20_ +// and underscore becomes _5F_. +// +// - They must start with an ASCII letter. To ensure this, we prefix +// all keys with "key-". +function keyToId(key) { + function escapeChar(match) { + return "_" + match.codePointAt(0).toString(16).toUpperCase() + "_"; + } + return "key-" + key.replace(/[^a-zA-Z0-9]/g, escapeChar); +} + +// Convert a Kinto ID back into a chrome.storage key. +// Returns null if a key couldn't be parsed. +function idToKey(id) { + function unescapeNumber(match, group1) { + return String.fromCodePoint(parseInt(group1, 16)); + } + // An escaped ID should match this regex. + // An escaped ID should consist of only letters and numbers, plus + // code points escaped as _[0-9a-f]+_. + const ESCAPED_ID_FORMAT = /^(?:[a-zA-Z0-9]|_[0-9A-F]+_)*$/; + + if (!id.startsWith("key-")) { + return null; + } + const unprefixed = id.slice(4); + // Verify that the ID is the correct format. + if (!ESCAPED_ID_FORMAT.test(unprefixed)) { + return null; + } + return unprefixed.replace(/_([0-9A-F]+)_/g, unescapeNumber); +} + +// An "id schema" used to validate Kinto IDs and generate new ones. +const storageSyncIdSchema = { + // We should never generate IDs; chrome.storage only acts as a + // key-value store, so we should always have a key. + generate() { + throw new Error("cannot generate IDs"); + }, + + // See keyToId and idToKey for more details. + validate(id) { + return idToKey(id) !== null; + }, +}; + +// An "id schema" used for the system collection, which doesn't +// require validation or generation of IDs. +const cryptoCollectionIdSchema = { + generate() { + throw new Error("cannot generate IDs for system collection"); + }, + + validate(id) { + return true; + }, +}; + +/** + * Wrapper around the crypto collection providing some handy utilities. + */ +class CryptoCollection { + constructor(fxaService) { + this._fxaService = fxaService; + } + + async getCollection() { + throwIfNoFxA(this._fxaService, "tried to access cryptoCollection"); + const { kinto } = await storageSyncInit(); + return kinto.collection(STORAGE_SYNC_CRYPTO_COLLECTION_NAME, { + idSchema: cryptoCollectionIdSchema, + remoteTransformers: [ + new KeyRingEncryptionRemoteTransformer(this._fxaService), + ], + }); + } + + /** + * Generate a new salt for use in hashing extension and record + * IDs. + * + * @returns {string} A base64-encoded string of the salt + */ + getNewSalt() { + return btoa( + lazy.CryptoUtils.generateRandomBytesLegacy( + STORAGE_SYNC_CRYPTO_SALT_LENGTH_BYTES + ) + ); + } + + /** + * Retrieve the keyring record from the crypto collection. + * + * You can use this if you want to check metadata on the keyring + * record rather than use the keyring itself. + * + * The keyring record, if present, should have the structure: + * + * - kbHash: a hash of the user's kB. When this changes, we will + * try to sync the collection. + * - uuid: a record identifier. This will only change when we wipe + * the collection (due to kB getting reset). + * - keys: a "WBO" form of a CollectionKeyManager. + * - salts: a normal JS Object with keys being collection IDs and + * values being base64-encoded salts to use when hashing IDs + * for that collection. + * + * @returns {Promise} + */ + async getKeyRingRecord() { + const collection = await this.getCollection(); + const cryptoKeyRecord = await collection.getAny( + STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID + ); + + let data = cryptoKeyRecord.data; + if (!data) { + // This is a new keyring. Invent an ID for this record. If this + // changes, it means a client replaced the keyring, so we need to + // reupload everything. + const uuid = Services.uuid.generateUUID().toString(); + data = { uuid, id: STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID }; + } + return data; + } + + async getSalts() { + const cryptoKeyRecord = await this.getKeyRingRecord(); + return cryptoKeyRecord && cryptoKeyRecord.salts; + } + + /** + * Used for testing with a known salt. + * + * @param {string} extensionId The extension ID for which to set a + * salt. + * @param {string} salt The salt to use for this extension, as a + * base64-encoded salt. + */ + async _setSalt(extensionId, salt) { + const cryptoKeyRecord = await this.getKeyRingRecord(); + cryptoKeyRecord.salts = cryptoKeyRecord.salts || {}; + cryptoKeyRecord.salts[extensionId] = salt; + await this.upsert(cryptoKeyRecord); + } + + /** + * Hash an extension ID for a given user so that an attacker can't + * identify the extensions a user has installed. + * + * The extension ID is assumed to be a string (i.e. series of + * code points), and its UTF8 encoding is prefixed with the salt + * for that collection and hashed. + * + * The returned hash must conform to the syntax for Kinto + * identifiers, which (as of this writing) must match + * [a-zA-Z0-9][a-zA-Z0-9_-]*. We thus encode the hash using + * "base64-url" without padding (so that we don't get any equals + * signs (=)). For fear that a hash could start with a hyphen + * (-) or an underscore (_), prefix it with "ext-". + * + * @param {string} extensionId The extension ID to obfuscate. + * @returns {Promise} A collection ID suitable for use to sync to. + */ + extensionIdToCollectionId(extensionId) { + return this.hashWithExtensionSalt( + lazy.CommonUtils.encodeUTF8(extensionId), + extensionId + ).then(hash => `ext-${hash}`); + } + + /** + * Hash some value with the salt for the given extension. + * + * The value should be a "bytestring", i.e. a string whose + * "characters" are values, each within [0, 255]. You can produce + * such a bytestring using e.g. CommonUtils.encodeUTF8. + * + * The returned value is a base64url-encoded string of the hash. + * + * @param {bytestring} value The value to be hashed. + * @param {string} extensionId The ID of the extension whose salt + * we should use. + * @returns {Promise} The hashed value. + */ + async hashWithExtensionSalt(value, extensionId) { + const salts = await this.getSalts(); + const saltBase64 = salts && salts[extensionId]; + if (!saltBase64) { + // This should never happen; salts should be populated before + // we need them by ensureCanSync. + throw new Error( + `no salt available for ${extensionId}; how did this happen?` + ); + } + + const hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(hasher.SHA256); + + const salt = atob(saltBase64); + const message = `${salt}\x00${value}`; + const hash = lazy.CryptoUtils.digestBytes(message, hasher); + return lazy.CommonUtils.encodeBase64URL(hash, false); + } + + /** + * Retrieve the actual keyring from the crypto collection. + * + * @returns {Promise} + */ + async getKeyRing() { + const cryptoKeyRecord = await this.getKeyRingRecord(); + const collectionKeys = new lazy.CollectionKeyManager(); + if (cryptoKeyRecord.keys) { + collectionKeys.setContents( + cryptoKeyRecord.keys, + cryptoKeyRecord.last_modified + ); + } else { + // We never actually use the default key, so it's OK if we + // generate one multiple times. + await collectionKeys.generateDefaultKey(); + } + // Pass through uuid field so that we can save it if we need to. + collectionKeys.uuid = cryptoKeyRecord.uuid; + return collectionKeys; + } + + async updateKBHash(kbHash) { + const coll = await this.getCollection(); + await coll.update( + { id: STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID, kbHash: kbHash }, + { patch: true } + ); + } + + async upsert(record) { + const collection = await this.getCollection(); + await collection.upsert(record); + } + + async sync(extensionStorageSyncKinto) { + const collection = await this.getCollection(); + return extensionStorageSyncKinto._syncCollection(collection, { + strategy: "server_wins", + }); + } + + /** + * Reset sync status for ALL collections by directly + * accessing the FirefoxAdapter. + */ + async resetSyncStatus() { + const coll = await this.getCollection(); + await coll.db.resetSyncStatus(); + } + + // Used only for testing. + async _clear() { + const collection = await this.getCollection(); + await collection.clear(); + } +} + +/** + * An EncryptionRemoteTransformer for extension records. + * + * It uses the special "keys" record to find a key for a given + * extension, thus its name + * CollectionKeyEncryptionRemoteTransformer. + * + * Also, during encryption, it will replace the ID of the new record + * with a hashed ID, using the salt for this collection. + * + * @param {string} extensionId The extension ID for which to find a key. + */ +let CollectionKeyEncryptionRemoteTransformer = class extends EncryptionRemoteTransformer { + constructor(cryptoCollection, keyring, extensionId) { + super(); + this.cryptoCollection = cryptoCollection; + this.keyring = keyring; + this.extensionId = extensionId; + } + + async getKeys() { + if (!this.keyring.hasKeysFor([this.extensionId])) { + // This should never happen. Keys should be created (and + // synced) at the beginning of the sync cycle. + throw new Error( + `tried to encrypt records for ${this.extensionId}, but key is not present` + ); + } + return this.keyring.keyForCollection(this.extensionId); + } + + getEncodedRecordId(record) { + // It isn't really clear whether kinto.js record IDs are + // bytestrings or strings that happen to only contain ASCII + // characters, so encode them to be sure. + const id = lazy.CommonUtils.encodeUTF8(record.id); + // Like extensionIdToCollectionId, the rules about Kinto record + // IDs preclude equals signs or strings starting with a + // non-alphanumeric, so prefix all IDs with a constant "id-". + return this.cryptoCollection + .hashWithExtensionSalt(id, this.extensionId) + .then(hash => `id-${hash}`); + } +}; + +/** + * Clean up now that one context is no longer using this extension's collection. + * + * @param {Extension} extension + * The extension whose context just ended. + * @param {Context} context + * The context that just ended. + */ +function cleanUpForContext(extension, context) { + const contexts = extensionContexts.get(extension); + contexts.delete(context); + if (contexts.size === 0) { + // Nobody else is using this collection. Clean up. + extensionContexts.delete(extension); + } +} + +/** + * Generate a promise that produces the Collection for an extension. + * + * @param {Extension} extension + * The extension whose collection needs to + * be opened. + * @param {object} options + * Options to be passed to the call to `.collection()`. + * @returns {Promise} + */ +const openCollection = async function (extension, options = {}) { + let collectionId = extension.id; + const { kinto } = await storageSyncInit(); + const coll = kinto.collection(collectionId, { + ...options, + idSchema: storageSyncIdSchema, + }); + return coll; +}; + +export class ExtensionStorageSyncKinto { + /** + * @param {FXAccounts} fxaService (Optional) If not + * present, trying to sync will fail. + */ + constructor(fxaService) { + this._fxaService = fxaService; + this.cryptoCollection = new CryptoCollection(fxaService); + this.listeners = new WeakMap(); + } + + /** + * Get a set of extensions to sync (including the ones with an + * active extension context that used the storage.sync API and + * the extensions that are enabled and have been synced before). + * + * @returns {Promise>} + * A promise which resolves to the set of the extensions to sync. + */ + async getExtensions() { + // Start from the set of the extensions with an active + // context that used the storage.sync APIs. + const extensions = new Set(extensionContexts.keys()); + + const allEnabledExtensions = await lazy.AddonManager.getAddonsByTypes([ + "extension", + ]); + + // Get the existing extension collections salts. + const keysRecord = await this.cryptoCollection.getKeyRingRecord(); + + // Add any enabled extensions that have been synced before. + for (const addon of allEnabledExtensions) { + if (this.hasSaltsFor(keysRecord, [addon.id])) { + const policy = WebExtensionPolicy.getByID(addon.id); + if (policy && policy.extension) { + extensions.add(policy.extension); + } + } + } + + return extensions; + } + + async syncAll() { + const extensions = await this.getExtensions(); + const extIds = Array.from(extensions, extension => extension.id); + log.debug(`Syncing extension settings for ${JSON.stringify(extIds)}`); + if (!extIds.length) { + // No extensions to sync. Get out. + return; + } + await this.ensureCanSync(extIds); + await this.checkSyncKeyRing(); + const keyring = await this.cryptoCollection.getKeyRing(); + const promises = Array.from(extensions, extension => { + const remoteTransformers = [ + new CollectionKeyEncryptionRemoteTransformer( + this.cryptoCollection, + keyring, + extension.id + ), + ]; + return openCollection(extension, { remoteTransformers }).then(coll => { + return this.sync(extension, coll); + }); + }); + await Promise.all(promises); + } + + async sync(extension, collection) { + throwIfNoFxA(this._fxaService, "syncing chrome.storage.sync"); + const isSignedIn = !!(await this._fxaService.getSignedInUser()); + if (!isSignedIn) { + // FIXME: this should support syncing to self-hosted + log.info("User was not signed into FxA; cannot sync"); + throw new Error("Not signed in to FxA"); + } + const collectionId = await this.cryptoCollection.extensionIdToCollectionId( + extension.id + ); + let syncResults; + try { + syncResults = await this._syncCollection(collection, { + strategy: "client_wins", + collection: collectionId, + }); + } catch (err) { + log.warn("Syncing failed", err); + throw err; + } + + let changes = {}; + for (const record of syncResults.created) { + changes[record.key] = { + newValue: record.data, + }; + } + for (const record of syncResults.updated) { + // N.B. It's safe to just pick old.key because it's not + // possible to "rename" a record in the storage.sync API. + const key = record.old.key; + changes[key] = { + oldValue: record.old.data, + newValue: record.new.data, + }; + } + for (const record of syncResults.deleted) { + changes[record.key] = { + oldValue: record.data, + }; + } + for (const resolution of syncResults.resolved) { + // FIXME: We can't send a "changed" notification because + // kinto.js only provides the newly-resolved value. But should + // we even send a notification? We use CLIENT_WINS so nothing + // has really "changed" on this end. (The change will come on + // the other end when it pulls down the update, which is handled + // by the "updated" case above.) If we are going to send a + // notification, what best values for "old" and "new"? This + // might violate client code's assumptions, since from their + // perspective, we were in state L, but this diff is from R -> + // L. + const accepted = resolution.accepted; + changes[accepted.key] = { + newValue: accepted.data, + }; + } + if (Object.keys(changes).length) { + this.notifyListeners(extension, changes); + } + log.info(`Successfully synced '${collection.name}'`); + } + + /** + * Utility function that handles the common stuff about syncing all + * Kinto collections (including "meta" collections like the crypto + * one). + * + * @param {Collection} collection + * @param {object} options + * Additional options to be passed to sync(). + * @returns {Promise} + */ + _syncCollection(collection, options) { + // FIXME: this should support syncing to self-hosted + return this._requestWithToken( + `Syncing ${collection.name}`, + function (token) { + const allOptions = Object.assign( + {}, + { + remote: lazy.prefStorageSyncServerURL, + headers: { + Authorization: "Bearer " + token, + }, + }, + options + ); + + return collection.sync(allOptions); + } + ); + } + + // Make a Kinto request with a current FxA token. + // If the response indicates that the token might have expired, + // retry the request. + async _requestWithToken(description, f) { + throwIfNoFxA( + this._fxaService, + "making remote requests from chrome.storage.sync" + ); + const fxaToken = await this._fxaService.getOAuthToken(FXA_OAUTH_OPTIONS); + try { + return await f(fxaToken); + } catch (e) { + if (e && e.response && e.response.status == 401) { + // Our token might have expired. Refresh and retry. + log.info("Token might have expired"); + await this._fxaService.removeCachedOAuthToken({ token: fxaToken }); + const newToken = await this._fxaService.getOAuthToken( + FXA_OAUTH_OPTIONS + ); + + // If this fails too, let it go. + return f(newToken); + } + // Otherwise, we don't know how to handle this error, so just reraise. + log.error(`${description}: request failed`, e); + throw e; + } + } + + /** + * Helper similar to _syncCollection, but for deleting the user's bucket. + * + * @returns {Promise} + */ + _deleteBucket() { + log.error("Deleting default bucket and everything in it"); + return this._requestWithToken("Clearing server", function (token) { + const headers = { Authorization: "Bearer " + token }; + const kintoHttp = new lazy.KintoHttpClient( + lazy.prefStorageSyncServerURL, + { + headers: headers, + timeout: KINTO_REQUEST_TIMEOUT, + } + ); + return kintoHttp.deleteBucket("default"); + }); + } + + async ensureSaltsFor(keysRecord, extIds) { + const newSalts = Object.assign({}, keysRecord.salts); + for (let collectionId of extIds) { + if (newSalts[collectionId]) { + continue; + } + + newSalts[collectionId] = this.cryptoCollection.getNewSalt(); + } + + return newSalts; + } + + /** + * Check whether the keys record (provided) already has salts for + * all the extensions given in extIds. + * + * @param {object} keysRecord A previously-retrieved keys record. + * @param {Array} extIds The IDs of the extensions which + * need salts. + * @returns {boolean} + */ + hasSaltsFor(keysRecord, extIds) { + if (!keysRecord.salts) { + return false; + } + + for (let collectionId of extIds) { + if (!keysRecord.salts[collectionId]) { + return false; + } + } + + return true; + } + + /** + * Recursive promise that terminates when our local collectionKeys, + * as well as that on the server, have keys for all the extensions + * in extIds. + * + * @param {Array} extIds + * The IDs of the extensions which need keys. + * @returns {Promise} + */ + async ensureCanSync(extIds) { + const keysRecord = await this.cryptoCollection.getKeyRingRecord(); + const collectionKeys = await this.cryptoCollection.getKeyRing(); + if ( + collectionKeys.hasKeysFor(extIds) && + this.hasSaltsFor(keysRecord, extIds) + ) { + return collectionKeys; + } + + log.info(`Need to create keys and/or salts for ${JSON.stringify(extIds)}`); + const kbHash = await getKBHash(this._fxaService); + const newKeys = await collectionKeys.ensureKeysFor(extIds); + const newSalts = await this.ensureSaltsFor(keysRecord, extIds); + const newRecord = { + id: STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID, + keys: newKeys.asWBO().cleartext, + salts: newSalts, + uuid: collectionKeys.uuid, + // Add a field for the current kB hash. + kbHash: kbHash, + }; + await this.cryptoCollection.upsert(newRecord); + const result = await this._syncKeyRing(newRecord); + if (result.resolved.length) { + // We had a conflict which was automatically resolved. We now + // have a new keyring which might have keys for the + // collections. Recurse. + return this.ensureCanSync(extIds); + } + + // No conflicts. We're good. + return newKeys; + } + + /** + * Update the kB in the crypto record. + */ + async updateKeyRingKB() { + throwIfNoFxA(this._fxaService, 'use of chrome.storage.sync "keyring"'); + const isSignedIn = !!(await this._fxaService.getSignedInUser()); + if (!isSignedIn) { + // Although this function is meant to be called on login, + // it's not unreasonable to check any time, even if we aren't + // logged in. + // + // If we aren't logged in, we don't have any information about + // the user's kB, so we can't be sure that the user changed + // their kB, so just return. + return; + } + + const thisKBHash = await getKBHash(this._fxaService); + await this.cryptoCollection.updateKBHash(thisKBHash); + } + + /** + * Make sure the keyring is up to date and synced. + * + * This is called on syncs to make sure that we don't sync anything + * to any collection unless the key for that collection is on the + * server. + */ + async checkSyncKeyRing() { + await this.updateKeyRingKB(); + + const cryptoKeyRecord = await this.cryptoCollection.getKeyRingRecord(); + if (cryptoKeyRecord && cryptoKeyRecord._status !== "synced") { + // We haven't successfully synced the keyring since the last + // change. This could be because kB changed and we touched the + // keyring, or it could be because we failed to sync after + // adding a key. Either way, take this opportunity to sync the + // keyring. + await this._syncKeyRing(cryptoKeyRecord); + } + } + + async _syncKeyRing(cryptoKeyRecord) { + throwIfNoFxA(this._fxaService, 'syncing chrome.storage.sync "keyring"'); + try { + // Try to sync using server_wins. + // + // We use server_wins here because whatever is on the server is + // at least consistent with itself -- the crypto in the keyring + // matches the crypto on the collection records. This is because + // we generate and upload keys just before syncing data. + // + // It's possible that we can't decode the version on the server. + // This can happen if a user is locked out of their account, and + // does a "reset password" to get in on a new device. In this + // case, we are in a bind -- we can't decrypt the record on the + // server, so we can't merge keys. If this happens, we try to + // figure out if we're the one with the correct (new) kB or if + // we just got locked out because we have the old kB. If we're + // the one with the correct kB, we wipe the server and reupload + // everything, including a new keyring. + // + // If another device has wiped the server, we need to reupload + // everything we have on our end too, so we detect this by + // adding a UUID to the keyring. UUIDs are preserved throughout + // the lifetime of a keyring, so the only time a keyring UUID + // changes is when a new keyring is uploaded, which only happens + // after a server wipe. So when we get a "conflict" (resolved by + // server_wins), we check whether the server version has a new + // UUID. If so, reset our sync status, so that we'll reupload + // everything. + const result = await this.cryptoCollection.sync(this); + if (result.resolved.length) { + // Automatically-resolved conflict. It should + // be for the keys record. + const resolutionIds = result.resolved.map(resolution => resolution.id); + if (resolutionIds > 1) { + // This should never happen -- there is only ever one record + // in this collection. + log.error( + `Too many resolutions for sync-storage-crypto collection: ${JSON.stringify( + resolutionIds + )}` + ); + } + const keyResolution = result.resolved[0]; + if (keyResolution.id != STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID) { + // This should never happen -- there should only ever be the + // keyring in this collection. + log.error( + `Strange conflict in sync-storage-crypto collection: ${JSON.stringify( + resolutionIds + )}` + ); + } + + // Due to a bug in the server-side code (see + // https://github.com/Kinto/kinto/issues/1209), lots of users' + // keyrings were deleted. We discover this by trying to push a + // new keyring (because the user aded a new extension), and we + // get a conflict. We have SERVER_WINS, so the client will + // accept this deleted keyring and delete it locally. Discover + // this and undo it. + if (keyResolution.accepted === null) { + log.error("Conflict spotted -- the server keyring was deleted"); + await this.cryptoCollection.upsert(keyResolution.rejected); + // It's possible that the keyring on the server that was + // deleted had keys for other extensions, which had already + // encrypted data. For this to happen, another client would + // have had to upload the keyring and then the delete happened + // before this client did a sync (and got the new extension + // and tried to sync the keyring again). Just to be safe, + // let's signal that something went wrong and we should wipe + // the bucket. + throw new ServerKeyringDeleted(); + } + + if (keyResolution.accepted.uuid != cryptoKeyRecord.uuid) { + log.info( + `Detected a new UUID (${keyResolution.accepted.uuid}, was ${cryptoKeyRecord.uuid}). Resetting sync status for everything.` + ); + await this.cryptoCollection.resetSyncStatus(); + + // Server version is now correct. Return that result. + return result; + } + } + // No conflicts, or conflict was just someone else adding keys. + return result; + } catch (e) { + if ( + KeyRingEncryptionRemoteTransformer.isOutdatedKB(e) || + e instanceof ServerKeyringDeleted || + // This is another way that ServerKeyringDeleted can + // manifest; see bug 1350088 for more details. + e.message.includes("Server has been flushed.") + ) { + // Check if our token is still valid, or if we got locked out + // between starting the sync and talking to Kinto. + const isSessionValid = await this._fxaService.checkAccountStatus(); + if (isSessionValid) { + log.error( + "Couldn't decipher old keyring; deleting the default bucket and resetting sync status" + ); + await this._deleteBucket(); + await this.cryptoCollection.resetSyncStatus(); + + // Reupload our keyring, which is the only new keyring. + // We don't want client_wins here because another device + // could have uploaded another keyring in the meantime. + return this.cryptoCollection.sync(this); + } + } + throw e; + } + } + + registerInUse(extension, context) { + // Register that the extension and context are in use. + const contexts = extensionContexts.get(extension); + if (!contexts.has(context)) { + // New context. Register it and make sure it cleans itself up + // when it closes. + contexts.add(context); + context.callOnClose({ + close: () => cleanUpForContext(extension, context), + }); + } + } + + /** + * Get the collection for an extension, and register the extension + * as being "in use". + * + * @param {Extension} extension + * The extension for which we are seeking + * a collection. + * @param {Context} context + * The context of the extension, so that we can + * stop syncing the collection when the extension ends. + * @returns {Promise} + */ + getCollection(extension, context) { + if (lazy.prefPermitsStorageSync !== true) { + return Promise.reject({ + message: `Please set ${STORAGE_SYNC_ENABLED_PREF} to true in about:config`, + }); + } + this.registerInUse(extension, context); + return openCollection(extension); + } + + async set(extension, items, context) { + const coll = await this.getCollection(extension, context); + const keys = Object.keys(items); + const ids = keys.map(keyToId); + const changes = await coll.execute( + txn => { + let changes = {}; + for (let [i, key] of keys.entries()) { + const id = ids[i]; + let item = items[key]; + let { oldRecord } = txn.upsert({ + id, + key, + data: item, + }); + changes[key] = { + newValue: item, + }; + if (oldRecord) { + // Extract the "data" field from the old record, which + // represents the value part of the key-value store + changes[key].oldValue = oldRecord.data; + } + } + return changes; + }, + { preloadIds: ids } + ); + this.notifyListeners(extension, changes); + } + + async remove(extension, keys, context) { + const coll = await this.getCollection(extension, context); + keys = [].concat(keys); + const ids = keys.map(keyToId); + let changes = {}; + await coll.execute( + txn => { + for (let [i, key] of keys.entries()) { + const id = ids[i]; + const res = txn.deleteAny(id); + if (res.deleted) { + changes[key] = { + oldValue: res.data.data, + }; + } + } + return changes; + }, + { preloadIds: ids } + ); + if (Object.keys(changes).length) { + this.notifyListeners(extension, changes); + } + } + + /* Wipe local data for all collections without causing the changes to be synced */ + async clearAll() { + const extensions = await this.getExtensions(); + const extIds = Array.from(extensions, extension => extension.id); + log.debug(`Clearing extension data for ${JSON.stringify(extIds)}`); + if (extIds.length) { + const promises = Array.from(extensions, extension => { + return openCollection(extension).then(coll => { + return coll.clear(); + }); + }); + await Promise.all(promises); + } + + // and clear the crypto collection. + const cc = await this.cryptoCollection.getCollection(); + await cc.clear(); + } + + async clear(extension, context) { + // We can't call Collection#clear here, because that just clears + // the local database. We have to explicitly delete everything so + // that the deletions can be synced as well. + const coll = await this.getCollection(extension, context); + const res = await coll.list(); + const records = res.data; + const keys = records.map(record => record.key); + await this.remove(extension, keys, context); + } + + async get(extension, spec, context) { + const coll = await this.getCollection(extension, context); + let keys, records; + if (spec === null) { + records = {}; + const res = await coll.list(); + for (let record of res.data) { + records[record.key] = record.data; + } + return records; + } + if (typeof spec === "string") { + keys = [spec]; + records = {}; + } else if (Array.isArray(spec)) { + keys = spec; + records = {}; + } else { + keys = Object.keys(spec); + records = Cu.cloneInto(spec, {}); + } + + for (let key of keys) { + const res = await coll.getAny(keyToId(key)); + if (res.data && res.data._status != "deleted") { + records[res.data.key] = res.data.data; + } + } + + return records; + } + + async getBytesInUse(extension, keys, context) { + // This is defined by the chrome spec as being the length of the key and + // the length of the json repr of the value. + let size = 0; + let data = await this.get(extension, keys, context); + for (const [key, value] of Object.entries(data)) { + size += key.length + JSON.stringify(value).length; + } + return size; + } + + addOnChangedListener(extension, listener, context) { + let listeners = this.listeners.get(extension) || new Set(); + listeners.add(listener); + this.listeners.set(extension, listeners); + + this.registerInUse(extension, context); + } + + removeOnChangedListener(extension, listener) { + let listeners = this.listeners.get(extension); + listeners.delete(listener); + if (listeners.size == 0) { + this.listeners.delete(extension); + } + } + + notifyListeners(extension, changes) { + lazy.Observers.notify("ext.storage.sync-changed"); + let listeners = this.listeners.get(extension) || new Set(); + if (listeners) { + for (let listener of listeners) { + lazy.ExtensionCommon.runSafeSyncWithoutClone(listener, changes); + } + } + } +} + +extensionStorageSyncKinto = new ExtensionStorageSyncKinto(_fxaService); + +// For test use only. +export const KintoStorageTestUtils = { + CollectionKeyEncryptionRemoteTransformer, + CryptoCollection, + EncryptionRemoteTransformer, + KeyRingEncryptionRemoteTransformer, + cleanUpForContext, + idToKey, + keyToId, +}; diff --git a/toolkit/components/extensions/ExtensionTelemetry.sys.mjs b/toolkit/components/extensions/ExtensionTelemetry.sys.mjs new file mode 100644 index 0000000000..06137b9a23 --- /dev/null +++ b/toolkit/components/extensions/ExtensionTelemetry.sys.mjs @@ -0,0 +1,343 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { DefaultWeakMap } = ExtensionUtils; + +// Map of the base histogram ids for the metrics recorded for the extensions. +const HISTOGRAMS_IDS = { + backgroundPageLoad: "WEBEXT_BACKGROUND_PAGE_LOAD_MS", + browserActionPopupOpen: "WEBEXT_BROWSERACTION_POPUP_OPEN_MS", + browserActionPreloadResult: "WEBEXT_BROWSERACTION_POPUP_PRELOAD_RESULT_COUNT", + contentScriptInjection: "WEBEXT_CONTENT_SCRIPT_INJECTION_MS", + eventPageRunningTime: "WEBEXT_EVENTPAGE_RUNNING_TIME_MS", + eventPageIdleResult: "WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT", + extensionStartup: "WEBEXT_EXTENSION_STARTUP_MS", + pageActionPopupOpen: "WEBEXT_PAGEACTION_POPUP_OPEN_MS", + storageLocalGetJson: "WEBEXT_STORAGE_LOCAL_GET_MS", + storageLocalSetJson: "WEBEXT_STORAGE_LOCAL_SET_MS", + storageLocalGetIdb: "WEBEXT_STORAGE_LOCAL_IDB_GET_MS", + storageLocalSetIdb: "WEBEXT_STORAGE_LOCAL_IDB_SET_MS", +}; + +const GLEAN_METRICS_TYPES = { + backgroundPageLoad: "timing_distribution", + browserActionPopupOpen: "timing_distribution", + browserActionPreloadResult: "labeled_counter", + contentScriptInjection: "timing_distribution", + eventPageRunningTime: "custom_distribution", + eventPageIdleResult: "labeled_counter", + extensionStartup: "timing_distribution", + pageActionPopupOpen: "timing_distribution", + storageLocalGetJson: "timing_distribution", + storageLocalSetJson: "timing_distribution", + storageLocalGetIdb: "timing_distribution", + storageLocalSetIdb: "timing_distribution", +}; + +/** + * Get a trimmed version of the given string if it is longer than 80 chars (used in telemetry + * when a string may be longer than allowed). + * + * @param {string} str + * The original string content. + * + * @returns {string} + * The trimmed version of the string when longer than 80 chars, or the given string + * unmodified otherwise. + */ +export function getTrimmedString(str) { + if (str.length <= 80) { + return str; + } + + const length = str.length; + + // Trim the string to prevent a flood of warnings messages logged internally by recordEvent, + // the trimmed version is going to be composed by the first 40 chars and the last 37 and 3 dots + // that joins the two parts, to visually indicate that the string has been trimmed. + return `${str.slice(0, 40)}...${str.slice(length - 37, length)}`; +} + +/** + * Get a string representing the error which can be included in telemetry data. + * If the resulting string is longer than 80 characters it is going to be + * trimmed using the `getTrimmedString` helper function. + * + * @param {Error | DOMException | Components.Exception} error + * The error object to convert into a string representation. + * + * @returns {string} + * - The `error.name` string on DOMException or Components.Exception + * (trimmed to 80 chars). + * - "NoError" if error is falsey. + * - "UnkownError" as a fallback. + */ +export function getErrorNameForTelemetry(error) { + let text = "UnknownError"; + if (!error) { + text = "NoError"; + } else if ( + DOMException.isInstance(error) || + error instanceof Components.Exception + ) { + text = error.name; + if (text.length > 80) { + text = getTrimmedString(text); + } + } + return text; +} + +/** + * This is a internal helper object which contains a collection of helpers used to make it easier + * to collect extension telemetry (in both the general histogram and in the one keyed by addon id). + * + * This helper object is not exported from ExtensionUtils, it is used by the ExtensionTelemetry + * Proxy which is exported and used by the callers to record telemetry data for one of the + * supported metrics. + */ +class ExtensionTelemetryMetric { + constructor(metric) { + this.metric = metric; + this.gleanTimerIdsMap = new DefaultWeakMap(ext => new WeakMap()); + } + + // Stopwatch methods. + stopwatchStart(extension, obj = extension) { + this._wrappedStopwatchMethod("start", this.metric, extension, obj); + this._wrappedTimingDistributionMethod("start", this.metric, extension, obj); + } + + stopwatchFinish(extension, obj = extension) { + this._wrappedStopwatchMethod("finish", this.metric, extension, obj); + this._wrappedTimingDistributionMethod( + "stopAndAccumulate", + this.metric, + extension, + obj + ); + } + + stopwatchCancel(extension, obj = extension) { + this._wrappedStopwatchMethod("cancel", this.metric, extension, obj); + this._wrappedTimingDistributionMethod( + "cancel", + this.metric, + extension, + obj + ); + } + + // Histogram counters methods. + histogramAdd(opts) { + this._histogramAdd(this.metric, opts); + } + + /** + * Wraps a call to Glean timing_distribution methods for a given metric and extension. + * + * @param {string} method + * The Glean timing_distribution method to call ("start", "stopAndAccumulate" or "cancel"). + * @param {string} metric + * The Glean timing_distribution metric to record (used to retrieve the Glean metric type from the + * GLEAN_METRICS_TYPES map). + * @param {Extension | BrowserExtensionContent} extension + * The extension to record the telemetry for. + * @param {any | undefined} [obj = extension] + * An optional object the timing_distribution method call should be related to + * (defaults to the extension parameter when missing). + */ + _wrappedTimingDistributionMethod(method, metric, extension, obj = extension) { + if (!extension) { + Cu.reportError(`Mandatory extension parameter is undefined`); + return; + } + + const gleanMetricType = GLEAN_METRICS_TYPES[metric]; + if (!gleanMetricType) { + Cu.reportError(`Unknown metric ${metric}`); + return; + } + + if (gleanMetricType !== "timing_distribution") { + Cu.reportError( + `Glean metric ${metric} is of type ${gleanMetricType}, expected timing_distribution` + ); + return; + } + + switch (method) { + case "start": { + const timerId = Glean.extensionsTiming[metric].start(); + this.gleanTimerIdsMap.get(extension).set(obj, timerId); + break; + } + case "stopAndAccumulate": // Intentional fall-through. + case "cancel": { + if ( + !this.gleanTimerIdsMap.has(extension) || + !this.gleanTimerIdsMap.get(extension).has(obj) + ) { + Cu.reportError( + `timerId not found for Glean timing_distribution ${metric}` + ); + return; + } + const timerId = this.gleanTimerIdsMap.get(extension).get(obj); + this.gleanTimerIdsMap.get(extension).delete(obj); + Glean.extensionsTiming[metric][method](timerId); + break; + } + default: + Cu.reportError( + `Unknown method ${method} call for Glean metric ${metric}` + ); + } + } + + /** + * Wraps a call to a TelemetryStopwatch method for a given metric and extension. + * + * @param {string} method + * The stopwatch method to call ("start", "finish" or "cancel"). + * @param {string} metric + * The stopwatch metric to record (used to retrieve the base histogram id from the HISTOGRAMS_IDS object). + * @param {Extension | BrowserExtensionContent} extension + * The extension to record the telemetry for. + * @param {any | undefined} [obj = extension] + * An optional telemetry stopwatch object (which defaults to the extension parameter when missing). + */ + _wrappedStopwatchMethod(method, metric, extension, obj = extension) { + if (!extension) { + Cu.reportError(`Mandatory extension parameter is undefined`); + return; + } + + const baseId = HISTOGRAMS_IDS[metric]; + if (!baseId) { + Cu.reportError(`Unknown metric ${metric}`); + return; + } + + // Record metric in the general histogram. + TelemetryStopwatch[method](baseId, obj); + + // Record metric in the histogram keyed by addon id. + let extensionId = getTrimmedString(extension.id); + TelemetryStopwatch[`${method}Keyed`]( + `${baseId}_BY_ADDONID`, + extensionId, + obj + ); + } + + /** + * Record a telemetry category and/or value for a given metric. + * + * @param {string} metric + * The metric to record (used to retrieve the base histogram id from the _histogram object). + * @param {object} options + * @param {Extension | BrowserExtensionContent} options.extension + * The extension to record the telemetry for. + * @param {string | undefined} [options.category] + * An optional histogram category. + * @param {number | undefined} [options.value] + * An optional value to record. + */ + _histogramAdd(metric, { category, extension, value }) { + if (!extension) { + Cu.reportError(`Mandatory extension parameter is undefined`); + return; + } + + const baseId = HISTOGRAMS_IDS[metric]; + if (!baseId) { + Cu.reportError(`Unknown metric ${metric}`); + return; + } + + const histogram = Services.telemetry.getHistogramById(baseId); + if (typeof category === "string") { + histogram.add(category, value); + } else { + histogram.add(value); + } + + const keyedHistogram = Services.telemetry.getKeyedHistogramById( + `${baseId}_BY_ADDONID` + ); + const extensionId = getTrimmedString(extension.id); + + if (typeof category === "string") { + keyedHistogram.add(extensionId, category, value); + } else { + keyedHistogram.add(extensionId, value); + } + + switch (GLEAN_METRICS_TYPES[metric]) { + case "custom_distribution": { + if (typeof category === "string") { + Cu.reportError( + `Unexpected unsupported category parameter set on Glean metric ${metric}` + ); + return; + } + // NOTE: extensionsTiming may become a property of the GLEAN_METRICS_TYPES + // map once we may introduce new histograms that are not part of the + // extensionsTiming Glean metrics category. + Glean.extensionsTiming[metric].accumulateSamples([value]); + break; + } + case "labeled_counter": { + if (typeof category !== "string") { + Cu.reportError( + `Missing mandatory category on adding data to labeled Glean metric ${metric}` + ); + return; + } + Glean.extensionsCounters[metric][category].add(value ?? 1); + break; + } + default: + Cu.reportError( + `Unexpected unsupported Glean metric type "${GLEAN_METRICS_TYPES[metric]}" for metric ${metric}` + ); + } + } +} + +// Cache of the ExtensionTelemetryMetric instances that has been lazily created by the +// Extension Telemetry Proxy. +/** @type {Map} */ +const metricsCache = new Map(); + +/** + * This proxy object provides the telemetry helpers for the currently supported metrics (the ones listed in + * HISTOGRAMS_IDS), the telemetry helpers for a particular metric are lazily created + * when the related property is being accessed on this object for the first time, e.g.: + * + * ExtensionTelemetry.extensionStartup.stopwatchStart(extension); + * ExtensionTelemetry.browserActionPreloadResult.histogramAdd({category: "Shown", extension}); + */ +export var ExtensionTelemetry = new Proxy(metricsCache, { + get(target, prop, receiver) { + // NOTE: if we would be start adding glean probes that do not have a unified + // telemetry histogram counterpart, we would need to change this check + // accordingly. + if (!(prop in HISTOGRAMS_IDS)) { + throw new Error(`Unknown metric ${String(prop)}`); + } + + // Lazily create and cache the metric result object. + if (!target.has(prop)) { + target.set(prop, new ExtensionTelemetryMetric(prop)); + } + + return target.get(prop); + }, +}); diff --git a/toolkit/components/extensions/ExtensionTestCommon.sys.mjs b/toolkit/components/extensions/ExtensionTestCommon.sys.mjs new file mode 100644 index 0000000000..94cb801cc5 --- /dev/null +++ b/toolkit/components/extensions/ExtensionTestCommon.sys.mjs @@ -0,0 +1,677 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +/** + * This module contains extension testing helper logic which is common + * between all test suites. + */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + Assert: "resource://testing-common/Assert.sys.mjs", + Extension: "resource://gre/modules/Extension.sys.mjs", + ExtensionData: "resource://gre/modules/Extension.sys.mjs", + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", + ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter( + lazy, + "apiManager", + () => lazy.ExtensionParent.apiManager +); + +import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { flushJarCache } = ExtensionUtils; + +const { instanceOf } = ExtensionCommon; + +/** + * A skeleton Extension-like object, used for testing, which installs an + * add-on via the add-on manager when startup() is called, and + * uninstalles it on shutdown(). + * + * @param {string} id + * @param {nsIFile} file + * @param {nsIURI} rootURI + * @param {string} installType + */ +export class MockExtension { + constructor(file, rootURI, addonData) { + this.id = null; + this.file = file; + this.rootURI = rootURI; + this.installType = addonData.useAddonManager; + this.addonData = addonData; + this.addon = null; + + let promiseEvent = eventName => + new Promise(resolve => { + let onstartup = async (msg, extension) => { + this.maybeSetID(extension.rootURI, extension.id); + if (!this.id && this.addonPromise) { + await this.addonPromise; + } + + if (extension.id == this.id) { + lazy.apiManager.off(eventName, onstartup); + this._extension = extension; + resolve(extension); + } + }; + lazy.apiManager.on(eventName, onstartup); + }); + + this._extension = null; + this._extensionPromise = promiseEvent("startup"); + this._readyPromise = promiseEvent("ready"); + this._uninstallPromise = promiseEvent("uninstall-complete"); + } + + maybeSetID(uri, id) { + if ( + !this.id && + uri instanceof Ci.nsIJARURI && + uri.JARFile.QueryInterface(Ci.nsIFileURL).file.equals(this.file) + ) { + this.id = id; + } + } + + testMessage(...args) { + return this._extension.testMessage(...args); + } + + get tabManager() { + return this._extension.tabManager; + } + + on(...args) { + this._extensionPromise.then(extension => { + extension.on(...args); + }); + // Extension.jsm emits a "startup" event on |extension| before emitting the + // "startup" event on |apiManager|. Trigger the "startup" event anyway, to + // make sure that the MockExtension behaves like an Extension with regards + // to the startup event. + if (args[0] === "startup" && !this._extension) { + this._extensionPromise.then(extension => { + args[1]("startup", extension); + }); + } + } + + off(...args) { + this._extensionPromise.then(extension => { + extension.off(...args); + }); + } + + _setIncognitoOverride() { + let { addonData } = this; + if (addonData && addonData.incognitoOverride) { + try { + let { id } = addonData.manifest.browser_specific_settings.gecko; + if (id) { + return ExtensionTestCommon.setIncognitoOverride({ id, addonData }); + } + } catch (e) {} + throw new Error( + "Extension ID is required for setting incognito permission." + ); + } + } + + async startup() { + await this._setIncognitoOverride(); + + if (this.installType == "temporary") { + return lazy.AddonManager.installTemporaryAddon(this.file).then( + async addon => { + this.addon = addon; + this.id = addon.id; + return this._readyPromise; + } + ); + } else if (this.installType == "permanent") { + this.addonPromise = new Promise(resolve => { + this.resolveAddon = resolve; + }); + let install = await lazy.AddonManager.getInstallForFile(this.file); + return new Promise((resolve, reject) => { + let listener = { + onInstallFailed: reject, + onInstallEnded: async (install, newAddon) => { + this.addon = newAddon; + this.id = newAddon.id; + this.resolveAddon(newAddon); + resolve(this._readyPromise); + }, + }; + + install.addListener(listener); + install.install(); + }); + } + throw new Error("installType must be one of: temporary, permanent"); + } + + shutdown() { + this.addon.uninstall(); + return this.cleanupGeneratedFile(); + } + + cleanupGeneratedFile() { + return this._extensionPromise + .then(extension => { + return extension.broadcast("Extension:FlushJarCache", { + path: this.file.path, + }); + }) + .then(() => { + return IOUtils.remove(this.file.path, { retryReadonly: true }); + }); + } + + terminateBackground(...args) { + return this._extensionPromise.then(extension => { + return extension.terminateBackground(...args); + }); + } + + wakeupBackground() { + return this._extensionPromise.then(extension => { + return extension.wakeupBackground(); + }); + } +} + +function provide(obj, keys, value, override = false) { + if (keys.length == 1) { + if (!(keys[0] in obj) || override) { + obj[keys[0]] = value; + } + } else { + if (!(keys[0] in obj)) { + obj[keys[0]] = {}; + } + provide(obj[keys[0]], keys.slice(1), value, override); + } +} + +// Some test assertions to work in both mochitest and xpcshell. This +// will be revisited later. +const ExtensionTestAssertions = { + getPersistentListeners(extWrapper, apiNs, apiEvent) { + let policy = WebExtensionPolicy.getByID(extWrapper.id); + const extension = policy?.extension || extWrapper.extension; + + if (!extension || !(extension instanceof lazy.Extension)) { + throw new Error( + `Unable to retrieve the Extension class instance for ${extWrapper.id}` + ); + } + + const { persistentListeners } = extension; + if ( + !persistentListeners?.size || + !persistentListeners.get(apiNs)?.has(apiEvent) + ) { + return []; + } + + return Array.from(persistentListeners.get(apiNs).get(apiEvent).values()); + }, + + assertPersistentListeners( + extWrapper, + apiNs, + apiEvent, + { primed, persisted = true, primedListenersCount } + ) { + if (primed && !persisted) { + throw new Error( + "Inconsistent assertion, can't assert a primed listener if it is not persisted" + ); + } + + let listenersInfo = ExtensionTestAssertions.getPersistentListeners( + extWrapper, + apiNs, + apiEvent + ); + lazy.Assert.equal( + persisted, + !!listenersInfo?.length, + `Got a persistent listener for ${apiNs}.${apiEvent}` + ); + for (const info of listenersInfo) { + if (primed) { + lazy.Assert.ok( + info.listeners.some(listener => listener.primed), + `${apiNs}.${apiEvent} listener expected to be primed` + ); + } else { + lazy.Assert.ok( + !info.listeners.some(listener => listener.primed), + `${apiNs}.${apiEvent} listener expected to not be primed` + ); + } + } + if (primed && primedListenersCount > 0) { + lazy.Assert.equal( + listenersInfo.reduce((acc, info) => { + acc += info.listeners.length; + return acc; + }, 0), + primedListenersCount, + `Got the expected number of ${apiNs}.${apiEvent} listeners to be primed` + ); + } + }, +}; + +export var ExtensionTestCommon = class ExtensionTestCommon { + static get testAssertions() { + return ExtensionTestAssertions; + } + + // Called by AddonTestUtils.promiseShutdownManager to reset startup promises + static resetStartupPromises() { + lazy.ExtensionParent._resetStartupPromises(); + } + + // Called to notify "browser-delayed-startup-finished", which resolves + // ExtensionParent.browserPaintedPromise. Thus must be resolved for + // primed listeners to be able to wake the extension. + static notifyEarlyStartup() { + Services.obs.notifyObservers(null, "browser-delayed-startup-finished"); + return lazy.ExtensionParent.browserPaintedPromise; + } + + // Called to notify "extensions-late-startup", which resolves + // ExtensionParent.browserStartupPromise. Normally, in Firefox, the + // notification would be "sessionstore-windows-restored", however + // mobile listens for "extensions-late-startup" so that is more useful + // in testing. + static notifyLateStartup() { + Services.obs.notifyObservers(null, "extensions-late-startup"); + return lazy.ExtensionParent.browserStartupPromise; + } + + /** + * Shortcut to more easily access WebExtensionPolicy.backgroundServiceWorkerEnabled + * from mochitest-plain tests. + * + * @returns {boolean} true if the background service worker are enabled. + */ + static getBackgroundServiceWorkerEnabled() { + return WebExtensionPolicy.backgroundServiceWorkerEnabled; + } + + /** + * A test helper mainly used to skip test tasks if running in "backgroundServiceWorker" test mode + * (e.g. while running test files shared across multiple test modes: e.g. in-process-webextensions, + * remote-webextensions, sw-webextensions etc.). + * + * The underlying pref "extension.backgroundServiceWorker.forceInTestExtension": + * - is set to true in the xpcshell-serviceworker.ini and mochitest-serviceworker.ini manifests + * (and so it is going to be set to true while running the test files listed in those manifests) + * - when set to true, all test extension using a background script without explicitly listing it + * in the test extension manifest will be automatically executed as background service workers + * (instead of background scripts loaded in a background page) + * + * @returns {boolean} true if the test is running in "background service worker mode" + */ + static isInBackgroundServiceWorkerTests() { + return Services.prefs.getBoolPref( + "extensions.backgroundServiceWorker.forceInTestExtension", + false + ); + } + + /** + * This code is designed to make it easy to test a WebExtension + * without creating a bunch of files. Everything is contained in a + * single JS object. + * + * Properties: + * "background": "" + * A script to be loaded as the background script. + * The "background" section of the "manifest" property is overwritten + * if this is provided. + * "manifest": {...} + * Contents of manifest.json + * "files": {"filename1": "contents1", ...} + * Data to be included as files. Can be referenced from the manifest. + * If a manifest file is provided here, it takes precedence over + * a generated one. Always use "/" as a directory separator. + * Directories should appear here only implicitly (as a prefix + * to file names) + * + * To make things easier, the value of "background" and "files"[] can + * be a function, which is converted to source that is run. + * + * @param {object} data + * @returns {object} + */ + static generateFiles(data) { + let files = {}; + + Object.assign(files, data.files); + + let manifest = data.manifest; + if (!manifest) { + manifest = {}; + } + + provide(manifest, ["name"], "Generated extension"); + provide(manifest, ["manifest_version"], 2); + provide(manifest, ["version"], "1.0"); + + // Make it easier to test same manifest in both MV2 and MV3 configurations. + if (manifest.manifest_version === 2 && manifest.host_permissions) { + manifest.permissions = [].concat( + manifest.permissions || [], + manifest.host_permissions + ); + delete manifest.host_permissions; + } + + if (data.useServiceWorker === undefined) { + // If we're force-testing service workers we will turn the background + // script part of ExtensionTestUtils test extensions into a background + // service worker. + data.useServiceWorker = + ExtensionTestCommon.isInBackgroundServiceWorkerTests(); + } + + // allowInsecureRequests is a shortcut to removing upgrade-insecure-requests from default csp. + if (data.allowInsecureRequests) { + // upgrade-insecure-requests is only added automatically to MV3. + // This flag is therefore not needed in MV2. + if (manifest.manifest_version < 3) { + throw new Error("allowInsecureRequests requires manifest_version 3"); + } + if (manifest.content_security_policy) { + throw new Error( + "allowInsecureRequests cannot be used with manifest.content_security_policy" + ); + } + manifest.content_security_policy = { + extension_pages: `script-src 'self'`, + }; + } + + if (data.background) { + let bgScript = Services.uuid.generateUUID().number + ".js"; + + // If persistent is set keep the flag. + let persistent = manifest.background?.persistent; + let scriptKey = data.useServiceWorker + ? ["background", "service_worker"] + : ["background", "scripts"]; + let scriptVal = data.useServiceWorker ? bgScript : [bgScript]; + provide(manifest, scriptKey, scriptVal, true); + provide(manifest, ["background", "persistent"], persistent); + + files[bgScript] = data.background; + } + + provide(files, ["manifest.json"], JSON.stringify(manifest)); + + for (let filename in files) { + let contents = files[filename]; + if (typeof contents == "function") { + files[filename] = this.serializeScript(contents); + } else if ( + typeof contents != "string" && + !instanceOf(contents, "ArrayBuffer") + ) { + files[filename] = JSON.stringify(contents); + } + } + + return files; + } + + /** + * Write an xpi file to disk for a webextension. + * The generated extension is stored in the system temporary directory, + * and an nsIFile object pointing to it is returned. + * + * @param {object} data In the format handled by generateFiles. + * @returns {nsIFile} + */ + static generateXPI(data) { + let files = this.generateFiles(data); + return this.generateZipFile(files); + } + + static generateZipFile(files, baseName = "generated-extension.xpi") { + let ZipWriter = Components.Constructor( + "@mozilla.org/zipwriter;1", + "nsIZipWriter" + ); + let zipW = new ZipWriter(); + + let file = new lazy.FileUtils.File( + PathUtils.join(PathUtils.tempDir, baseName) + ); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, lazy.FileUtils.PERMS_FILE); + + const MODE_WRONLY = 0x02; + const MODE_TRUNCATE = 0x20; + zipW.open(file, MODE_WRONLY | MODE_TRUNCATE); + + // Needs to be in microseconds for some reason. + let time = Date.now() * 1000; + + function generateFile(filename) { + let components = filename.split("/"); + let path = ""; + for (let component of components.slice(0, -1)) { + path += component + "/"; + if (!zipW.hasEntry(path)) { + zipW.addEntryDirectory(path, time, false); + } + } + } + + for (let filename in files) { + let script = files[filename]; + if (!instanceOf(script, "ArrayBuffer")) { + script = new TextEncoder().encode(script).buffer; + } + + let stream = Cc[ + "@mozilla.org/io/arraybuffer-input-stream;1" + ].createInstance(Ci.nsIArrayBufferInputStream); + stream.setData(script, 0, script.byteLength); + + generateFile(filename); + zipW.addEntryStream(filename, time, 0, stream, false); + } + + zipW.close(); + + return file; + } + + /** + * Properly serialize a function into eval-able code string. + * + * @param {Function} script + * @returns {string} + */ + static serializeFunction(script) { + // Serialization of object methods doesn't include `function` anymore. + const method = /^(async )?(?:(\w+)|"(\w+)\.js")\(/; + + let code = script.toString(); + let match = code.match(method); + if (match && match[2] !== "function") { + code = code.replace(method, "$1function $2$3("); + } + return code; + } + + /** + * Properly serialize a script into eval-able code string. + * + * @param {string | Function | Array} script + * @returns {string} + */ + static serializeScript(script) { + if (Array.isArray(script)) { + return Array.from(script, this.serializeScript, this).join(";"); + } + if (typeof script !== "function") { + return script; + } + return `(${this.serializeFunction(script)})();`; + } + + static setIncognitoOverride(extension) { + let { id, addonData } = extension; + if (!addonData || !addonData.incognitoOverride) { + return; + } + if (addonData.incognitoOverride == "not_allowed") { + return lazy.ExtensionPermissions.remove(id, { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], + }); + } + return lazy.ExtensionPermissions.add(id, { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], + }); + } + + static setExtensionID(data) { + try { + if (data.manifest.browser_specific_settings.gecko.id) { + return; + } + } catch (e) { + // No ID is set. + } + provide( + data, + ["manifest", "browser_specific_settings", "gecko", "id"], + Services.uuid.generateUUID().number + ); + } + + /** + * Generates a new extension using |Extension.generateXPI|, and initializes a + * new |Extension| instance which will execute it. + * + * @param {object} data + * @returns {Partial} + */ + static generate(data) { + if (data.useAddonManager === "android-only") { + // Some extension APIs are partially implemented in Java, and the + // interface between the JS and Java side (GeckoViewWebExtension) + // expects extensions to be registered with the AddonManager. + // This is at least necessary for tests that use the following APIs: + // - browserAction/pageAction. + // - tabs.create, tabs.update, tabs.remove (uses GeckoViewTabBridge). + // - downloads API + // - browsingData API (via ExtensionBrowsingData.sys.mjs). + // + // In xpcshell tests, the AddonManager is optional, so the AddonManager + // cannot unconditionally be enabled. + // In mochitests, tests are run in an actual browser, so the AddonManager + // is always enabled and hence useAddonManager is always set by default. + if (AppConstants.platform === "android") { + // Many MV3 tests set temporarilyInstalled for granted_host_permissions. + // The granted_host_permissions flag is only effective for temporarily + // installed extensions, so make sure to use "temporary" in this case. + if (data.temporarilyInstalled) { + data.useAddonManager = "temporary"; + } else { + data.useAddonManager = "permanent"; + } + // MockExtension requires data.manifest.applications.gecko.id to be set. + // The AddonManager requires an ID in the manifest for unsigned XPIs. + this.setExtensionID(data); + } else { + // On non-Android, default to not using the AddonManager. + data.useAddonManager = null; + } + } + + let file = this.generateXPI(data); + + flushJarCache(file.path); + Services.ppmm.broadcastAsyncMessage("Extension:FlushJarCache", { + path: file.path, + }); + + let fileURI = Services.io.newFileURI(file); + let jarURI = Services.io.newURI("jar:" + fileURI.spec + "!/"); + + // This may be "temporary" or "permanent". + if (data.useAddonManager) { + return new MockExtension(file, jarURI, data); + } + + let id; + if (data.manifest) { + if (data.manifest.applications && data.manifest.applications.gecko) { + id = data.manifest.applications.gecko.id; + } else if ( + data.manifest.browser_specific_settings && + data.manifest.browser_specific_settings.gecko + ) { + id = data.manifest.browser_specific_settings.gecko.id; + } + } + if (!id) { + id = Services.uuid.generateUUID().number; + } + + let signedState = lazy.AddonManager.SIGNEDSTATE_SIGNED; + if (data.isPrivileged) { + signedState = lazy.AddonManager.SIGNEDSTATE_PRIVILEGED; + } + if (data.isSystem) { + signedState = lazy.AddonManager.SIGNEDSTATE_SYSTEM; + } + + let isPrivileged = lazy.ExtensionData.getIsPrivileged({ + signedState, + builtIn: false, + temporarilyInstalled: !!data.temporarilyInstalled, + }); + + return new lazy.Extension( + { + id, + resourceURI: jarURI, + cleanupFile: file, + signedState, + incognitoOverride: data.incognitoOverride, + temporarilyInstalled: !!data.temporarilyInstalled, + isPrivileged, + TEST_NO_ADDON_MANAGER: true, + // By default we set TEST_NO_DELAYED_STARTUP to true + TEST_NO_DELAYED_STARTUP: !data.delayedStartup, + }, + data.startupReason ?? "ADDON_INSTALL" + ); + } +}; diff --git a/toolkit/components/extensions/ExtensionUtils.sys.mjs b/toolkit/components/extensions/ExtensionUtils.sys.mjs new file mode 100644 index 0000000000..cbdf900d14 --- /dev/null +++ b/toolkit/components/extensions/ExtensionUtils.sys.mjs @@ -0,0 +1,349 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +// xpcshell doesn't handle idle callbacks well. +ChromeUtils.defineLazyGetter(lazy, "idleTimeout", () => + Services.appinfo.name === "XPCShell" ? 500 : undefined +); + +// It would be nicer to go through `Services.appinfo`, but some tests need to be +// able to replace that field with a custom implementation before it is first +// called. +// eslint-disable-next-line mozilla/use-services +const appinfo = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime); + +let nextId = 0; +const uniqueProcessID = appinfo.uniqueProcessID; +// Store the process ID in a 16 bit field left shifted to end of a +// double's mantissa. +// Note: We can't use bitwise ops here, since they truncate to a 32 bit +// integer and we need all 53 mantissa bits. +const processIDMask = (uniqueProcessID & 0xffff) * 2 ** 37; + +function getUniqueId() { + // Note: We can't use bitwise ops here, since they truncate to a 32 bit + // integer and we need all 53 mantissa bits. + return processIDMask + nextId++; +} + +function promiseTimeout(delay) { + return new Promise(resolve => lazy.setTimeout(resolve, delay)); +} + +/** + * An Error subclass for which complete error messages are always passed + * to extensions, rather than being interpreted as an unknown error. + */ +class ExtensionError extends DOMException { + constructor(message) { + super(message, "ExtensionError"); + } + // Custom JS classes can't survive IPC, so need to check error name. + static [Symbol.hasInstance](e) { + return DOMException.isInstance(e) && e.name === "ExtensionError"; + } +} + +function filterStack(error) { + return String(error.stack).replace( + /(^.*(Task\.jsm|Promise-backend\.js).*\n)+/gm, + "\n" + ); +} + +/** + * An Error subclass used to recognize the errors that should + * to be forwarded to the worker thread and being accessible + * to the extension worker script (vs. the errors that should be + * only logged internally and raised to the worker script as + * the generic unexpected error). + */ +class WorkerExtensionError extends DOMException { + constructor(message) { + super(message, "Error"); + } +} + +/** + * Similar to a WeakMap, but creates a new key with the given + * constructor if one is not present. + */ +// @ts-ignore (https://github.com/microsoft/TypeScript/issues/56664) +class DefaultWeakMap extends WeakMap { + constructor(defaultConstructor = undefined, init = undefined) { + super(init); + if (defaultConstructor) { + this.defaultConstructor = defaultConstructor; + } + } + + get(key) { + let value = super.get(key); + if (value === undefined && !this.has(key)) { + value = this.defaultConstructor(key); + this.set(key, value); + } + return value; + } +} + +class DefaultMap extends Map { + constructor(defaultConstructor = undefined, init = undefined) { + super(init); + if (defaultConstructor) { + this.defaultConstructor = defaultConstructor; + } + } + + get(key) { + let value = super.get(key); + if (value === undefined && !this.has(key)) { + value = this.defaultConstructor(key); + this.set(key, value); + } + return value; + } +} + +function getInnerWindowID(window) { + return window.windowGlobalChild?.innerWindowId; +} + +/** + * A set with a limited number of slots, which flushes older entries as + * newer ones are added. + * + * @param {integer} limit + * The maximum size to trim the set to after it grows too large. + * @param {integer} [slop = limit * .25] + * The number of extra entries to allow in the set after it + * reaches the size limit, before it is truncated to the limit. + * @param {Iterable} [iterable] + * An iterable of initial entries to add to the set. + */ +class LimitedSet extends Set { + constructor(limit, slop = Math.round(limit * 0.25), iterable = undefined) { + super(iterable); + this.limit = limit; + this.slop = slop; + } + + truncate(limit) { + for (let item of this) { + // Live set iterators can ge relatively expensive, since they need + // to be updated after every modification to the set. Since + // breaking out of the loop early will keep the iterator alive + // until the next full GC, we're currently better off finishing + // the entire loop even after we're done truncating. + if (this.size > limit) { + this.delete(item); + } + } + } + + add(item) { + if (this.size >= this.limit + this.slop && !this.has(item)) { + this.truncate(this.limit - 1); + } + return super.add(item); + } +} + +/** + * Returns a Promise which resolves when the given document's DOM has + * fully loaded. + * + * @param {Document} doc The document to await the load of. + * @returns {Promise} + */ +function promiseDocumentReady(doc) { + if (doc.readyState == "interactive" || doc.readyState == "complete") { + return Promise.resolve(doc); + } + + return new Promise(resolve => { + doc.addEventListener( + "DOMContentLoaded", + function onReady(event) { + if (event.target === event.currentTarget) { + doc.removeEventListener("DOMContentLoaded", onReady, true); + resolve(doc); + } + }, + true + ); + }); +} + +/** + * Returns a Promise which resolves when the given window's document's DOM has + * fully loaded, the stylesheets have fully loaded, and we have hit an + * idle time. + * + * @param {Window} window The window whose document we will await + the readiness of. + * @returns {Promise} + */ +function promiseDocumentIdle(window) { + return window.document.documentReadyForIdle.then(() => { + return new Promise(resolve => + window.requestIdleCallback(resolve, { timeout: lazy.idleTimeout }) + ); + }); +} + +/** + * Returns a Promise which resolves when the given document is fully + * loaded. + * + * @param {Document} doc The document to await the load of. + * @returns {Promise} + */ +function promiseDocumentLoaded(doc) { + if (doc.readyState == "complete") { + return Promise.resolve(doc); + } + + return new Promise(resolve => { + doc.defaultView.addEventListener("load", () => resolve(doc), { + once: true, + }); + }); +} + +/** + * Returns a Promise which resolves when the given event is dispatched to the + * given element. + * + * @param {Element} element + * The element on which to listen. + * @param {string} eventName + * The event to listen for. + * @param {boolean} [useCapture = true] + * If true, listen for the even in the capturing rather than + * bubbling phase. + * @param {function(Event): boolean} [test] + * An optional test function which, when called with the + * observer's subject and data, should return true if this is the + * expected event, false otherwise. + * @returns {Promise} + */ +function promiseEvent( + element, + eventName, + useCapture = true, + test = event => true +) { + return new Promise(resolve => { + function listener(event) { + if (test(event)) { + element.removeEventListener(eventName, listener, useCapture); + resolve(event); + } + } + element.addEventListener(eventName, listener, useCapture); + }); +} + +/** + * Returns a Promise which resolves the given observer topic has been + * observed. + * + * @param {string} topic + * The topic to observe. + * @param {function(any, string): boolean} [test] + * An optional test function which, when called with the + * observer's subject and data, should return true if this is the + * expected notification, false otherwise. + * @returns {Promise} + */ +function promiseObserved(topic, test = () => true) { + return new Promise(resolve => { + let observer = (subject, topic, data) => { + if (test(subject, data)) { + Services.obs.removeObserver(observer, topic); + resolve({ subject, data }); + } + }; + Services.obs.addObserver(observer, topic); + }); +} + +function getMessageManager(target) { + if (target.frameLoader) { + return target.frameLoader.messageManager; + } + return target; +} + +function flushJarCache(jarPath) { + Services.obs.notifyObservers(null, "flush-cache-entry", jarPath); +} +function parseMatchPatterns(patterns, options) { + try { + return new MatchPatternSet(patterns, options); + } catch (e) { + let pattern; + for (pattern of patterns) { + try { + new MatchPattern(pattern, options); + } catch (e) { + throw new ExtensionError(`Invalid url pattern: ${pattern}`); + } + } + // Unexpectedly MatchPatternSet threw, but MatchPattern did not. + throw e; + } +} + +/** + * Fetch icon content and convert it to a data: URI. + * + * @param {string} iconUrl Icon url to fetch. + * @returns {Promise} + */ +async function makeDataURI(iconUrl) { + let response; + try { + response = await fetch(iconUrl); + } catch (e) { + // Failed to fetch, ignore engine's favicon. + Cu.reportError(e); + return; + } + let buffer = await response.arrayBuffer(); + let contentType = response.headers.get("content-type"); + let bytes = new Uint8Array(buffer); + let str = String.fromCharCode.apply(null, bytes); + return `data:${contentType};base64,${btoa(str)}`; +} + +export var ExtensionUtils = { + flushJarCache, + getInnerWindowID, + getMessageManager, + getUniqueId, + filterStack, + makeDataURI, + parseMatchPatterns, + promiseDocumentIdle, + promiseDocumentLoaded, + promiseDocumentReady, + promiseEvent, + promiseObserved, + promiseTimeout, + DefaultMap, + DefaultWeakMap, + ExtensionError, + LimitedSet, + WorkerExtensionError, +}; diff --git a/toolkit/components/extensions/ExtensionWorkerChild.sys.mjs b/toolkit/components/extensions/ExtensionWorkerChild.sys.mjs new file mode 100644 index 0000000000..f93f6968e9 --- /dev/null +++ b/toolkit/components/extensions/ExtensionWorkerChild.sys.mjs @@ -0,0 +1,818 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +/** + * This file handles extension background service worker logic that runs in the + * child process. + */ + +import { + ExtensionChild, + ExtensionActivityLogChild, +} from "resource://gre/modules/ExtensionChild.sys.mjs"; + +import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; +import { + ExtensionPageChild, + getContextChildManagerGetter, +} from "resource://gre/modules/ExtensionPageChild.sys.mjs"; +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { BaseContext, redefineGetter } = ExtensionCommon; + +const { + ChildAPIManager, + ChildLocalAPIImplementation, + MessageEvent, + Messenger, + Port, + ProxyAPIImplementation, + SimpleEventAPI, +} = ExtensionChild; + +const { DefaultMap, getUniqueId } = ExtensionUtils; + +/** + * SimpleEventAPI subclass specialized for the worker port events + * used by WorkerMessenger. + */ +class WorkerRuntimePortEvent extends SimpleEventAPI { + api() { + return { + ...super.api(), + createListenerForAPIRequest: (...args) => + this.createListenerForAPIRequest(...args), + }; + } + + createListenerForAPIRequest(request) { + const { eventListener } = request; + return function (port, ...args) { + return eventListener.callListener(args, { + apiObjectType: Ci.mozIExtensionListenerCallOptions.RUNTIME_PORT, + apiObjectDescriptor: { portId: port.portId, name: port.name }, + }); + }; + } +} + +/** + * SimpleEventAPI subclass specialized for the worker runtime messaging events + * used by WorkerMessenger. + */ +class WorkerMessageEvent extends MessageEvent { + api() { + return { + ...super.api(), + createListenerForAPIRequest: (...args) => + this.createListenerForAPIRequest(...args), + }; + } + + createListenerForAPIRequest(request) { + const { eventListener } = request; + return function (message, sender) { + return eventListener.callListener([message, sender], { + eventListenerType: + Ci.mozIExtensionListenerCallOptions.CALLBACK_SEND_RESPONSE, + }); + }; + } +} + +/** + * MessageEvent subclass specialized for the worker's port API events + * used by WorkerPort. + */ +class WorkerPortEvent extends SimpleEventAPI { + api() { + return { + ...super.api(), + createListenerForAPIRequest: (...args) => + this.createListenerForAPIRequest(...args), + }; + } + + createListenerForAPIRequest(request) { + const { eventListener } = request; + switch (this.name) { + case "Port.onDisconnect": + return function (port) { + eventListener.callListener([], { + apiObjectType: Ci.mozIExtensionListenerCallOptions.RUNTIME_PORT, + apiObjectDescriptor: { + portId: port.portId, + name: port.name, + }, + }); + }; + case "Port.onMessage": + return function (message, port) { + eventListener.callListener([message], { + apiObjectType: Ci.mozIExtensionListenerCallOptions.RUNTIME_PORT, + apiObjectDescriptor: { + portId: port.portId, + name: port.name, + }, + }); + }; + } + return undefined; + } +} + +/** + * Port subclass specialized for the workers and used by WorkerMessager. + */ +class WorkerPort extends Port { + constructor(context, portId, name, native, sender) { + const { viewType, contextId } = context; + if (viewType !== "background_worker") { + throw new Error( + `Unexpected viewType "${viewType}" on context ${contextId}` + ); + } + + super(context, portId, name, native, sender); + this.portId = portId; + } + + initEventManagers() { + const { context } = this; + this.onMessage = new WorkerPortEvent(context, "Port.onMessage"); + this.onDisconnect = new WorkerPortEvent(context, "Port.onDisconnect"); + } + + getAPI() { + const api = super.getAPI(); + // Add the portId to the API object, needed by the WorkerMessenger + // to retrieve the port given the apiObjectId part of the + // mozIExtensionAPIRequest sent from the ExtensionPort webidl. + api.portId = this.portId; + return api; + } + + get api() { + // No need to clone this for the worker, it's on a separate JSRuntime. + return redefineGetter(this, "api", this.getAPI()); + } +} + +/** + * A Messenger subclass specialized for the background service worker. + */ +class WorkerMessenger extends Messenger { + constructor(context) { + const { viewType, contextId } = context; + if (viewType !== "background_worker") { + throw new Error( + `Unexpected viewType "${viewType}" on context ${contextId}` + ); + } + + super(context); + + // Used by WebIDL API requests to get a port instance given the apiObjectId + // received in the API request coming from the ExtensionPort instance + // returned in the thread where the request was originating from. + this.portsById = new Map(); + this.context.callOnClose(this); + } + + initEventManagers() { + const { context } = this; + this.onConnect = new WorkerRuntimePortEvent(context, "runtime.onConnect"); + this.onConnectEx = new WorkerRuntimePortEvent( + context, + "runtime.onConnectExternal" + ); + this.onMessage = new WorkerMessageEvent(this.context, "runtime.onMessage"); + this.onMessageEx = new WorkerMessageEvent( + context, + "runtime.onMessageExternal" + ); + } + + close() { + this.portsById.clear(); + } + + getPortById(portId) { + return this.portsById.get(portId); + } + + /** + * @typedef {object} ExtensionPortDescriptor + * https://phabricator.services.mozilla.com/D196385?id=801874#inline-1093734 + * + * @returns {ExtensionPortDescriptor} + */ + connect({ name, native, ...args }) { + let portId = getUniqueId(); + let port = new WorkerPort(this.context, portId, name, !!native); + this.conduit + .queryPortConnect({ portId, name, native, ...args }) + .catch(error => port.recvPortDisconnect({ error })); + this.portsById.set(`${portId}`, port); + // Extension worker calls this method through the WebIDL bindings, + // and the Port instance returned by the runtime.connect/connectNative + // methods will be an instance of ExtensionPort webidl interface based + // on the ExtensionPortDescriptor dictionary returned by this method. + return { portId, name }; + } + + recvPortConnect({ extensionId, portId, name, sender }) { + let event = sender.id === extensionId ? this.onConnect : this.onConnectEx; + if (this.context.active && event.fires.size) { + let port = new WorkerPort(this.context, portId, name, false, sender); + this.portsById.set(`${port.portId}`, port); + return event.emit(port).length; + } + } +} + +/** + * APIImplementation subclass specialized for handling mozIExtensionAPIRequests + * originated from webidl bindings. + * + * Provides a createListenerForAPIRequest method which is used by + * WebIDLChildAPIManager to retrieve an API event specific wrapper + * for the mozIExtensionEventListener for the API events that needs + * special handling (e.g. runtime.onConnect). + * + * createListenerForAPIRequest delegates to the API event the creation + * of the special event listener wrappers, the EventManager api objects + * for the events that needs special wrapper are expected to provide + * a method with the same name. + */ +class ChildLocalWebIDLAPIImplementation extends ChildLocalAPIImplementation { + constructor(pathObj, namespace, name, childApiManager) { + super(pathObj, namespace, name, childApiManager); + this.childApiManager = childApiManager; + } + + createListenerForAPIRequest(request) { + return this.pathObj[this.name].createListenerForAPIRequest?.(request); + } + + setProperty() { + // mozIExtensionAPIRequest doesn't support this requestType at the moment, + // setting a pref would just replace the previous value on the wrapper + // object living in the owner thread. + // To be implemented if we have an actual use case where that is needed. + throw new Error("Unexpected call to setProperty"); + } + + hasListener(listener) { + // hasListener is implemented in C++ by ExtensionEventManager, and so + // a call to this method is unexpected. + throw new Error("Unexpected call to hasListener"); + } +} + +/** + * APIImplementation subclass specialized for handling API requests related + * to an API Object type. + * + * Retrieving the apiObject instance is delegated internally to the + * ExtensionAPI subclass that implements the request apiNamespace, + * through an optional getAPIObjectForRequest method expected to be + * available on the ExtensionAPI class. + */ +class ChildWebIDLObjectTypeImplementation extends ChildLocalWebIDLAPIImplementation { + constructor(request, childApiManager) { + const { apiNamespace, apiName, apiObjectType, apiObjectId } = request; + const api = childApiManager.getExtensionAPIInstance(apiNamespace); + const pathObj = api.getAPIObjectForRequest?.( + childApiManager.context, + request + ); + if (!pathObj) { + throw new Error(`apiObject instance not found for ${request}`); + } + super(pathObj, apiNamespace, apiName, childApiManager); + this.fullname = `${apiNamespace}.${apiObjectType}(${apiObjectId}).${apiName}`; + } +} + +/** + * A ChildAPIManager subclass specialized for handling mozIExtensionAPIRequest + * originated from the WebIDL bindings. + * + * Currently used only for the extension contexts related to the background + * service worker. + */ +class WebIDLChildAPIManager extends ChildAPIManager { + constructor(...args) { + super(...args); + // Map> + // + // apiPathToEventString is a string that represents the full API path + // related to the event name (e.g. "runtime.onConnect", or "runtime.Port.onMessage") + this.eventListenerWrappers = new DefaultMap(() => new WeakMap()); + } + + getImplementation(namespace, name) { + this.apiCan.findAPIPath(`${namespace}.${name}`); + let obj = this.apiCan.findAPIPath(namespace); + + if (obj && name in obj) { + return new ChildLocalWebIDLAPIImplementation(obj, namespace, name, this); + } + + return this.getFallbackImplementation(namespace, name); + } + + getImplementationForRequest(request) { + const { apiNamespace, apiName, apiObjectType } = request; + if (apiObjectType) { + return new ChildWebIDLObjectTypeImplementation(request, this); + } + return this.getImplementation(apiNamespace, apiName); + } + + /** + * Handles an ExtensionAPIRequest originated by the Extension APIs WebIDL bindings. + * + * @param {mozIExtensionAPIRequest} request + * The object that represents the API request received + * (including arguments, an event listener wrapper etc) + * + * @returns {mozIExtensionAPIRequestResult} + * Result for the API request, either a value to be returned + * (which has to be a value that can be structure cloned + * if the request was originated from the worker thread) or + * an error to raise to the extension code. + */ + handleWebIDLAPIRequest(request) { + try { + const impl = this.getImplementationForRequest(request); + let result; + this.context.withAPIRequest(request, () => { + if (impl instanceof ProxyAPIImplementation) { + result = this.handleForProxyAPIImplementation(request, impl); + } else { + result = this.callAPIImplementation(request, impl); + } + }); + + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: result, + }; + } catch (error) { + return this.handleExtensionError(error); + } + } + + /** + * Convert an error raised while handling an API request, + * into the expected mozIExtensionAPIRequestResult. + * + * @param {Error | WorkerExtensionError} error + * @returns {mozIExtensionAPIRequestResult} + */ + + handleExtensionError(error) { + // Propagate an extension error to the caller on the worker thread. + if (error instanceof this.context.Error) { + return { + type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR, + value: error, + }; + } + + // Otherwise just log it and throw a generic error. + Cu.reportError(error); + return { + type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR, + value: new this.context.Error("An unexpected error occurred"), + }; + } + + /** + * Handle the given mozIExtensionAPIRequest using the given + * APIImplementation instance. + * + * @param {mozIExtensionAPIRequest} request + * @param {ChildLocalWebIDLAPIImplementation | ProxyAPIImplementation} impl + * @returns {any} + * @throws {Error | WorkerExtensionError} + */ + + callAPIImplementation(request, impl) { + const { requestType, normalizedArgs } = request; + + switch (requestType) { + // TODO (Bug 1728328): follow up to take callAsyncFunction requireUserInput + // parameter into account (until then callAsyncFunction, callFunction + // and callFunctionNoReturn calls do not differ yet). + case "callAsyncFunction": + case "callFunction": + case "callFunctionNoReturn": + case "getProperty": + return impl[requestType](normalizedArgs); + case "addListener": { + const listener = this.getOrCreateListenerWrapper(request, impl); + impl.addListener(listener, normalizedArgs); + + return undefined; + } + case "removeListener": { + const listener = this.getListenerWrapper(request); + if (listener) { + // Remove the previously added listener and forget the cleanup + // observer previously passed to context.callOnClose. + listener._callOnClose.close(); + this.context.forgetOnClose(listener._callOnClose); + this.forgetListenerWrapper(request); + } + return undefined; + } + default: + throw new Error( + `Unexpected requestType ${requestType} while handling "${request}"` + ); + } + } + + /** + * Handle the given mozIExtensionAPIRequest using the given + * ProxyAPIImplementation instance. + * + * @param {mozIExtensionAPIRequest} request + * @param {ProxyAPIImplementation} impl + * @returns {any} + * @throws {Error | WorkerExtensionError} + */ + handleForProxyAPIImplementation(request, impl) { + const { requestType } = request; + switch (requestType) { + case "callAsyncFunction": + case "callFunctionNoReturn": + case "addListener": + case "removeListener": + return this.callAPIImplementation(request, impl); + default: + // Any other request types (e.g. getProperty or callFunction) are + // unexpected and so we raise a more detailed error to be logged + // on the browser console (while the extension will receive the + // generic "An unexpected error occurred" one). + throw new Error( + `Unexpected requestType ${requestType} while handling "${request}"` + ); + } + } + + getAPIPathForWebIDLRequest(request) { + const { apiNamespace, apiName, apiObjectType } = request; + if (apiObjectType) { + return `${apiNamespace}.${apiObjectType}.${apiName}`; + } + + return `${apiNamespace}.${apiName}`; + } + + /** + * Return an ExtensionAPI class instance given its namespace. + * + * @param {string} namespace + * @returns {ExtensionAPI} + */ + getExtensionAPIInstance(namespace) { + return this.apiCan.apis.get(namespace); + } + + getOrCreateListenerWrapper(request, impl) { + let listener = this.getListenerWrapper(request); + if (listener) { + return listener; + } + + // Look for special wrappers that are needed for some API events + // (e.g. runtime.onMessage/onConnect/...). + if (impl instanceof ChildLocalWebIDLAPIImplementation) { + listener = impl.createListenerForAPIRequest(request); + } + + const { eventListener } = request; + listener = + listener ?? + function (...args) { + // Default wrapper just forwards all the arguments to the + // extension callback (all arguments has to be structure cloneable + // if the extension callback is on the worker thread). + eventListener.callListener(args); + }; + listener._callOnClose = { + close: () => { + this.eventListenerWrappers.delete(eventListener); + // Failing to send the request to remove the listener in the parent + // process shouldn't prevent the extension or context shutdown, + // otherwise we would leak a WebExtensionPolicy instance. + try { + impl.removeListener(listener); + } catch (err) { + // Removing a listener when the extension context is being closed can + // fail if the API is proxied to the parent process and the conduit + // has been already closed, and so we ignore the error if we are not + // processing a call proxied to the parent process. + if (impl instanceof ChildLocalWebIDLAPIImplementation) { + Cu.reportError(err); + } + } + }, + }; + this.storeListenerWrapper(request, listener); + this.context.callOnClose(listener._callOnClose); + return listener; + } + + getListenerWrapper(request) { + const { eventListener } = request; + if (!(eventListener instanceof Ci.mozIExtensionEventListener)) { + throw new Error(`Unexpected eventListener type for request: ${request}`); + } + const apiPath = this.getAPIPathForWebIDLRequest(request); + if (!this.eventListenerWrappers.has(apiPath)) { + return undefined; + } + return this.eventListenerWrappers.get(apiPath).get(eventListener); + } + + storeListenerWrapper(request, listener) { + const { eventListener } = request; + if (!(eventListener instanceof Ci.mozIExtensionEventListener)) { + throw new Error(`Missing eventListener for request: ${request}`); + } + const apiPath = this.getAPIPathForWebIDLRequest(request); + this.eventListenerWrappers.get(apiPath).set(eventListener, listener); + } + + forgetListenerWrapper(request) { + const { eventListener } = request; + if (!(eventListener instanceof Ci.mozIExtensionEventListener)) { + throw new Error(`Missing eventListener for request: ${request}`); + } + const apiPath = this.getAPIPathForWebIDLRequest(request); + if (this.eventListenerWrappers.has(apiPath)) { + this.eventListenerWrappers.get(apiPath).delete(eventListener); + } + } +} + +class WorkerContextChild extends BaseContext { + /** + * This WorkerContextChild represents an addon execution environment + * that is running on the worker thread in an extension child process. + * + * @param {BrowserExtensionContent} extension This context's owner. + * @param {object} params + * @param {mozIExtensionServiceWorkerInfo} params.serviceWorkerInfo + */ + constructor(extension, { serviceWorkerInfo }) { + if ( + !serviceWorkerInfo?.scriptURL || + !serviceWorkerInfo?.clientInfoId || + !serviceWorkerInfo?.principal + ) { + throw new Error("Missing or invalid serviceWorkerInfo"); + } + + super("addon_child", extension); + this.viewType = "background_worker"; + this.uri = Services.io.newURI(serviceWorkerInfo.scriptURL); + this.workerClientInfoId = serviceWorkerInfo.clientInfoId; + this.workerDescriptorId = serviceWorkerInfo.descriptorId; + this.workerPrincipal = serviceWorkerInfo.principal; + this.incognito = serviceWorkerInfo.principal.privateBrowsingId > 0; + + // A mozIExtensionAPIRequest being processed (set by the withAPIRequest + // method while executing a given callable, can be optionally used by + // the API implementation methods to access the mozIExtensionAPIRequest + // being processed and customize their result if necessary to handle + // requests originated by the webidl bindings). + this.webidlAPIRequest = null; + + // This context uses a plain object as a cloneScope (anyway the values + // moved across thread are going to be automatically serialized/deserialized + // as structure clone data, we may remove this if we are changing the + // internals to not use the context.cloneScope). + this.workerCloneScope = { + Promise, + // The instances of this Error constructor will be recognized by the + // ExtensionAPIRequestHandler as errors that should be propagated to + // the worker thread and received by extension code that originated + // the API request. + Error: ExtensionUtils.WorkerExtensionError, + }; + } + + getCreateProxyContextData() { + const { workerDescriptorId } = this; + return { workerDescriptorId }; + } + + openConduit(subject, address) { + let proc = ChromeUtils.domProcessChild; + let conduit = proc.getActor("ProcessConduits").openConduit(subject, { + id: subject.id || getUniqueId(), + extensionId: this.extension.id, + envType: this.envType, + workerScriptURL: this.uri.spec, + workerDescriptorId: this.workerDescriptorId, + ...address, + }); + this.callOnClose(conduit); + conduit.setCloseCallback(() => { + this.forgetOnClose(conduit); + }); + return conduit; + } + + notifyWorkerLoaded() { + this.childManager.conduit.sendContextLoaded({ + childId: this.childManager.id, + extensionId: this.extension.id, + workerDescriptorId: this.workerDescriptorId, + }); + } + + withAPIRequest(request, callable) { + this.webidlAPIRequest = request; + try { + return callable(); + } finally { + this.webidlAPIRequest = null; + } + } + + getAPIRequest() { + return this.webidlAPIRequest; + } + + /** + * Captures the most recent stack frame from the WebIDL API request being + * processed. + * + * @returns {SavedFrame?} + */ + getCaller() { + return this.webidlAPIRequest?.callerSavedFrame; + } + + logActivity(type, name, data) { + ExtensionActivityLogChild.log(this, type, name, data); + } + + get cloneScope() { + return this.workerCloneScope; + } + + get principal() { + return this.workerPrincipal; + } + + get tabId() { + return -1; + } + + get useWebIDLBindings() { + return true; + } + + shutdown() { + this.unload(); + } + + unload() { + if (this.unloaded) { + return; + } + + super.unload(); + } + + get childManager() { + const childManager = getContextChildManagerGetter( + { envType: "addon_parent" }, + WebIDLChildAPIManager + ).call(this); + return redefineGetter(this, "childManager", childManager); + } + + get messenger() { + return redefineGetter(this, "messenger", new WorkerMessenger(this)); + } +} + +export var ExtensionWorkerChild = { + /** @type {Map} */ + extensionWorkerContexts: new Map(), + + apiManager: ExtensionPageChild.apiManager, + + /** + * Create an extension worker context (on a mozExtensionAPIRequest with + * requestType "initWorkerContext"). + * + * @param {BrowserExtensionContent} extension + * The extension for which the context should be created. + * @param {mozIExtensionServiceWorkerInfo} serviceWorkerInfo + */ + initExtensionWorkerContext(extension, serviceWorkerInfo) { + if (!WebExtensionPolicy.isExtensionProcess) { + throw new Error( + "Cannot create an extension worker context in current process" + ); + } + + const swId = serviceWorkerInfo.descriptorId; + let context = this.extensionWorkerContexts.get(swId); + if (context) { + if (context.extension !== extension) { + throw new Error( + "A different extension context already exists for this service worker" + ); + } + throw new Error( + "An extension context was already initialized for this service worker" + ); + } + + context = new WorkerContextChild(extension, { serviceWorkerInfo }); + this.extensionWorkerContexts.set(swId, context); + }, + + /** + * Get an existing extension worker context for the given extension and + * service worker. + * + * @param {BrowserExtensionContent} extension + * The extension for which the context should be created. + * @param {mozIExtensionServiceWorkerInfo} serviceWorkerInfo + * + * @returns {WorkerContextChild} + */ + getExtensionWorkerContext(extension, serviceWorkerInfo) { + if (!serviceWorkerInfo) { + return null; + } + + const context = this.extensionWorkerContexts.get( + serviceWorkerInfo.descriptorId + ); + + if (context?.extension === extension) { + return context; + } + + return null; + }, + + /** + * Notify the main process when an extension worker script has been loaded. + * + * @param {number} descriptorId The service worker descriptor ID of the destroyed context. + * @param {WebExtensionPolicy} policy + */ + notifyExtensionWorkerContextLoaded(descriptorId, policy) { + let context = this.extensionWorkerContexts.get(descriptorId); + if (context) { + if (context.extension.id !== policy.id) { + Cu.reportError( + new Error( + `ServiceWorker ${descriptorId} does not belong to the expected extension: ${policy.id}` + ) + ); + return; + } + context.notifyWorkerLoaded(); + } + }, + + /** + * Close the WorkerContextChild belonging to the given service worker, if any. + * + * @param {number} descriptorId The service worker descriptor ID of the destroyed context. + */ + destroyExtensionWorkerContext(descriptorId) { + let context = this.extensionWorkerContexts.get(descriptorId); + if (context) { + context.unload(); + this.extensionWorkerContexts.delete(descriptorId); + } + }, + + shutdownExtension(extensionId) { + for (let [workerClientInfoId, context] of this.extensionWorkerContexts) { + if (context.extension.id == extensionId) { + context.shutdown(); + this.extensionWorkerContexts.delete(workerClientInfoId); + } + } + }, +}; diff --git a/toolkit/components/extensions/ExtensionXPCShellUtils.sys.mjs b/toolkit/components/extensions/ExtensionXPCShellUtils.sys.mjs new file mode 100644 index 0000000000..165c37fac1 --- /dev/null +++ b/toolkit/components/extensions/ExtensionXPCShellUtils.sys.mjs @@ -0,0 +1,780 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCShellContentUtils } from "resource://testing-common/XPCShellContentUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs", + ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + Management: "resource://gre/modules/Extension.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", +}); + +let BASE_MANIFEST = Object.freeze({ + browser_specific_settings: Object.freeze({ + gecko: Object.freeze({ + id: "test@web.ext", + }), + }), + + manifest_version: 2, + + name: "name", + version: "0", +}); + +class ExtensionWrapper { + /** @type {AddonWrapper} */ + addon; + /** @type {Promise} */ + addonPromise; + /** @type {nsIFile[]} */ + cleanupFiles; + + constructor(testScope, extension = null) { + this.testScope = testScope; + + this.extension = null; + + this.handleResult = this.handleResult.bind(this); + this.handleMessage = this.handleMessage.bind(this); + + this.state = "uninitialized"; + + this.testResolve = null; + this.testDone = new Promise(resolve => { + this.testResolve = resolve; + }); + + this.messageHandler = new Map(); + this.messageAwaiter = new Map(); + + this.messageQueue = new Set(); + + this.testScope.registerCleanupFunction(() => { + this.clearMessageQueues(); + + if (this.state == "pending" || this.state == "running") { + this.testScope.equal( + this.state, + "unloaded", + "Extension left running at test shutdown" + ); + return this.unload(); + } else if (this.state == "unloading") { + this.testScope.equal( + this.state, + "unloaded", + "Extension not fully unloaded at test shutdown" + ); + } + this.destroy(); + }); + + if (extension) { + this.id = extension.id; + this.attachExtension(extension); + } + } + + destroy() { + // This method should be implemented in subclasses which need to + // perform cleanup when destroyed. + } + + attachExtension(extension) { + if (extension === this.extension) { + return; + } + + if (this.extension) { + this.extension.off("test-eq", this.handleResult); + this.extension.off("test-log", this.handleResult); + this.extension.off("test-result", this.handleResult); + this.extension.off("test-done", this.handleResult); + this.extension.off("test-message", this.handleMessage); + this.clearMessageQueues(); + } + this.uuid = extension.uuid; + this.extension = extension; + + extension.on("test-eq", this.handleResult); + extension.on("test-log", this.handleResult); + extension.on("test-result", this.handleResult); + extension.on("test-done", this.handleResult); + extension.on("test-message", this.handleMessage); + + this.testScope.info(`Extension attached`); + } + + clearMessageQueues() { + if (this.messageQueue.size) { + let names = Array.from(this.messageQueue, ([msg]) => msg); + this.testScope.equal( + JSON.stringify(names), + "[]", + "message queue is empty" + ); + this.messageQueue.clear(); + } + if (this.messageAwaiter.size) { + let names = Array.from(this.messageAwaiter.keys()); + this.testScope.equal( + JSON.stringify(names), + "[]", + "no tasks awaiting on messages" + ); + for (let promise of this.messageAwaiter.values()) { + promise.reject(); + } + this.messageAwaiter.clear(); + } + } + + handleResult(kind, pass, msg, expected, actual) { + switch (kind) { + case "test-eq": + this.testScope.ok( + pass, + `${msg} - Expected: ${expected}, Actual: ${actual}` + ); + break; + + case "test-log": + this.testScope.info(msg); + break; + + case "test-result": + this.testScope.ok(pass, msg); + break; + + case "test-done": + this.testScope.ok(pass, msg); + this.testResolve(msg); + break; + } + } + + handleMessage(kind, msg, ...args) { + let handler = this.messageHandler.get(msg); + if (handler) { + handler(...args); + } else { + this.messageQueue.add([msg, ...args]); + this.checkMessages(); + } + } + + awaitStartup() { + return this.startupPromise; + } + + awaitBackgroundStarted() { + if (!this.extension.manifest.background) { + throw new Error("Extension has no background"); + } + return Promise.all([ + this.startupPromise, + this.extension.promiseBackgroundStarted(), + ]); + } + + async startup() { + if (this.state != "uninitialized") { + throw new Error("Extension already started"); + } + this.state = "pending"; + + await lazy.ExtensionTestCommon.setIncognitoOverride(this.extension); + + this.startupPromise = this.extension.startup().then( + result => { + this.state = "running"; + + return result; + }, + error => { + this.state = "failed"; + + return Promise.reject(error); + } + ); + + return this.startupPromise; + } + + async unload() { + if (this.state != "running") { + throw new Error("Extension not running"); + } + this.state = "unloading"; + + if (this.addonPromise) { + // If addonPromise is still pending resolution, wait for it to make sure + // that add-ons that are installed through the AddonManager are properly + // uninstalled. + await this.addonPromise; + } + + if (this.addon) { + await this.addon.uninstall(); + } else { + await this.extension.shutdown(); + } + + if (AppConstants.platform === "android") { + // We need a way to notify the embedding layer that an extension has been + // uninstalled, so that the java layer can be updated too. + Services.obs.notifyObservers( + null, + "testing-uninstalled-addon", + this.addon ? this.addon.id : this.extension.id + ); + } + + this.state = "unloaded"; + } + + /** + * This method sends the message to force-sleep the background scripts. + * + * @returns {Promise} resolves after the background is asleep and listeners primed. + */ + terminateBackground(...args) { + return this.extension.terminateBackground(...args); + } + + wakeupBackground() { + return this.extension.wakeupBackground(); + } + + sendMessage(...args) { + this.extension.testMessage(...args); + } + + awaitFinish(msg) { + return this.testDone.then(actual => { + if (msg) { + this.testScope.equal(actual, msg, "test result correct"); + } + return actual; + }); + } + + checkMessages() { + for (let message of this.messageQueue) { + let [msg, ...args] = message; + + let listener = this.messageAwaiter.get(msg); + if (listener) { + this.messageQueue.delete(message); + this.messageAwaiter.delete(msg); + + listener.resolve(...args); + return; + } + } + } + + checkDuplicateListeners(msg) { + if (this.messageHandler.has(msg) || this.messageAwaiter.has(msg)) { + throw new Error("only one message handler allowed"); + } + } + + awaitMessage(msg) { + return new Promise((resolve, reject) => { + this.checkDuplicateListeners(msg); + + this.messageAwaiter.set(msg, { resolve, reject }); + this.checkMessages(); + }); + } + + onMessage(msg, callback) { + this.checkDuplicateListeners(msg); + this.messageHandler.set(msg, callback); + } +} + +class AOMExtensionWrapper extends ExtensionWrapper { + constructor(testScope) { + super(testScope); + + this.onEvent = this.onEvent.bind(this); + + lazy.Management.on("ready", this.onEvent); + lazy.Management.on("shutdown", this.onEvent); + lazy.Management.on("startup", this.onEvent); + + lazy.AddonTestUtils.on("addon-manager-shutdown", this.onEvent); + lazy.AddonTestUtils.on("addon-manager-started", this.onEvent); + + lazy.AddonManager.addAddonListener(this); + } + + destroy() { + this.id = null; + this.addon = null; + + lazy.Management.off("ready", this.onEvent); + lazy.Management.off("shutdown", this.onEvent); + lazy.Management.off("startup", this.onEvent); + + lazy.AddonTestUtils.off("addon-manager-shutdown", this.onEvent); + lazy.AddonTestUtils.off("addon-manager-started", this.onEvent); + + lazy.AddonManager.removeAddonListener(this); + } + + setRestarting() { + if (this.state !== "restarting") { + this.startupPromise = new Promise(resolve => { + this.resolveStartup = resolve; + }).then(async result => { + await this.addonPromise; + return result; + }); + } + this.state = "restarting"; + } + + onEnabling(addon) { + if (addon.id === this.id) { + this.setRestarting(); + } + } + + onInstalling(addon) { + if (addon.id === this.id) { + this.setRestarting(); + } + } + + onInstalled(addon) { + if (addon.id === this.id) { + this.addon = addon; + } + } + + onUninstalled(addon) { + if (addon.id === this.id) { + this.destroy(); + } + } + + onEvent(kind, ...args) { + switch (kind) { + case "addon-manager-started": + if (this.state === "uninitialized") { + // startup() not called yet, ignore AddonManager startup notification. + return; + } + this.addonPromise = lazy.AddonManager.getAddonByID(this.id).then( + addon => { + this.addon = addon; + this.addonPromise = null; + } + ); + // FALLTHROUGH + case "addon-manager-shutdown": + if (this.state === "uninitialized") { + return; + } + this.addon = null; + + this.setRestarting(); + break; + + case "startup": { + let [extension] = args; + + this.maybeSetID(extension.rootURI, extension.id); + + if (extension.id === this.id) { + this.attachExtension(extension); + this.state = "pending"; + } + break; + } + + case "shutdown": { + let [extension] = args; + if (extension.id === this.id && this.state !== "restarting") { + this.state = "unloaded"; + } + break; + } + + case "ready": { + let [extension] = args; + if (extension.id === this.id) { + this.state = "running"; + if (AppConstants.platform === "android") { + // We need a way to notify the embedding layer that a new extension + // has been installed, so that the java layer can be updated too. + Services.obs.notifyObservers( + null, + "testing-installed-addon", + extension.id + ); + } + this.resolveStartup(extension); + } + break; + } + } + } + + async _flushCache() { + if (this.extension && this.extension.rootURI instanceof Ci.nsIJARURI) { + let file = this.extension.rootURI.JARFile.QueryInterface( + Ci.nsIFileURL + ).file; + await Services.ppmm.broadcastAsyncMessage("Extension:FlushJarCache", { + path: file.path, + }); + } + } + + get version() { + return this.addon && this.addon.version; + } + + async unload() { + await this._flushCache(); + return super.unload(); + } + + /** + * Override for subclasses which don't set an ID in the constructor. + * + * @param {nsIURI} uri + * @param {string} id + */ + maybeSetID(uri, id) {} +} + +class InstallableWrapper extends AOMExtensionWrapper { + constructor(testScope, xpiFile, addonData = {}) { + super(testScope); + + this.file = xpiFile; + this.addonData = addonData; + this.installType = addonData.useAddonManager || "temporary"; + this.installTelemetryInfo = addonData.amInstallTelemetryInfo; + + this.cleanupFiles = [xpiFile]; + } + + destroy() { + super.destroy(); + + for (let file of this.cleanupFiles.splice(0)) { + try { + Services.obs.notifyObservers(file, "flush-cache-entry"); + file.remove(false); + } catch (e) { + Cu.reportError(e); + } + } + } + + maybeSetID(uri, id) { + if ( + !this.id && + uri instanceof Ci.nsIJARURI && + uri.JARFile.QueryInterface(Ci.nsIFileURL).file.equals(this.file) + ) { + this.id = id; + } + } + + _setIncognitoOverride() { + // this.id is not set yet so grab it from the manifest data to set + // the incognito permission. + let { addonData } = this; + if (addonData && addonData.incognitoOverride) { + try { + let { id } = addonData.manifest.browser_specific_settings.gecko; + if (id) { + return lazy.ExtensionTestCommon.setIncognitoOverride({ + id, + addonData, + }); + } + } catch (e) {} + throw new Error( + "Extension ID is required for setting incognito permission." + ); + } + } + + async _install(xpiFile) { + await this._setIncognitoOverride(); + + if (this.installType === "temporary") { + return lazy.AddonManager.installTemporaryAddon(xpiFile) + .then(addon => { + this.id = addon.id; + this.addon = addon; + + return this.startupPromise; + }) + .catch(e => { + this.state = "unloaded"; + return Promise.reject(e); + }); + } else if (this.installType === "permanent") { + return lazy.AddonManager.getInstallForFile( + xpiFile, + null, + this.installTelemetryInfo + ).then(install => { + let listener = { + onDownloadFailed: () => { + this.state = "unloaded"; + this.resolveStartup(Promise.reject(new Error("Install failed"))); + }, + onInstallFailed: () => { + this.state = "unloaded"; + this.resolveStartup(Promise.reject(new Error("Install failed"))); + }, + onInstallEnded: (install, newAddon) => { + this.id = newAddon.id; + this.addon = newAddon; + }, + }; + + install.addListener(listener); + install.install(); + + return this.startupPromise; + }); + } + } + + startup() { + if (this.state != "uninitialized") { + throw new Error("Extension already started"); + } + + this.state = "pending"; + this.startupPromise = new Promise(resolve => { + this.resolveStartup = resolve; + }); + + return this._install(this.file); + } + + async upgrade(data) { + this.startupPromise = new Promise(resolve => { + this.resolveStartup = resolve; + }); + this.state = "restarting"; + + await this._flushCache(); + + let xpiFile = lazy.ExtensionTestCommon.generateXPI(data); + + this.cleanupFiles.push(xpiFile); + + return this._install(xpiFile); + } +} + +class ExternallyInstalledWrapper extends AOMExtensionWrapper { + constructor(testScope, id) { + super(testScope); + + this.id = id; + this.startupPromise = new Promise(resolve => { + this.resolveStartup = resolve; + }); + + this.state = "restarting"; + } +} + +export var ExtensionTestUtils = { + BASE_MANIFEST, + + get testAssertions() { + return lazy.ExtensionTestCommon.testAssertions; + }, + + // Shortcut to more easily access WebExtensionPolicy.backgroundServiceWorkerEnabled + // from mochitest-plain tests. + getBackgroundServiceWorkerEnabled() { + return lazy.ExtensionTestCommon.getBackgroundServiceWorkerEnabled(); + }, + + // A test helper used to check if the pref "extension.backgroundServiceWorker.forceInTestExtension" + // is set to true. + isInBackgroundServiceWorkerTests() { + return lazy.ExtensionTestCommon.isInBackgroundServiceWorkerTests(); + }, + + async normalizeManifest( + manifest, + manifestType = "manifest.WebExtensionManifest", + baseManifest = BASE_MANIFEST + ) { + await lazy.Management.lazyInit(); + + manifest = Object.assign({}, baseManifest, manifest); + + let errors = []; + let context = { + url: null, + manifestVersion: manifest.manifest_version, + + logError: error => { + errors.push(error); + }, + + preprocessors: {}, + }; + + let normalized = lazy.Schemas.normalize(manifest, manifestType, context); + normalized.errors = errors; + + return normalized; + }, + + currentScope: null, + + profileDir: null, + + init(scope) { + XPCShellContentUtils.ensureInitialized(scope); + + this.currentScope = scope; + + this.profileDir = scope.do_get_profile(); + + let tmpD = this.profileDir.clone(); + tmpD.append("tmp"); + tmpD.create(Ci.nsIFile.DIRECTORY_TYPE, lazy.FileUtils.PERMS_DIRECTORY); + + let dirProvider = { + getFile(prop, persistent) { + persistent.value = false; + if (prop == "TmpD") { + return tmpD.clone(); + } + return null; + }, + + QueryInterface: ChromeUtils.generateQI(["nsIDirectoryServiceProvider"]), + }; + Services.dirsvc.registerProvider(dirProvider); + + scope.registerCleanupFunction(() => { + try { + tmpD.remove(true); + } catch (e) { + Cu.reportError(e); + } + Services.dirsvc.unregisterProvider(dirProvider); + + this.currentScope = null; + }); + }, + + addonManagerStarted: false, + + mockAppInfo() { + lazy.AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "48", + "48" + ); + }, + + startAddonManager() { + if (this.addonManagerStarted) { + return; + } + this.addonManagerStarted = true; + this.mockAppInfo(); + + return lazy.AddonTestUtils.promiseStartupManager(); + }, + + loadExtension(data) { + if (data.useAddonManager) { + // If we're using incognitoOverride, we'll need to ensure + // an ID is available before generating the XPI. + if (data.incognitoOverride) { + lazy.ExtensionTestCommon.setExtensionID(data); + } + let xpiFile = lazy.ExtensionTestCommon.generateXPI(data); + + return this.loadExtensionXPI(xpiFile, data); + } + + let extension = lazy.ExtensionTestCommon.generate(data); + + return new ExtensionWrapper(this.currentScope, extension); + }, + + loadExtensionXPI(xpiFile, data) { + return new InstallableWrapper(this.currentScope, xpiFile, data); + }, + + // Create a wrapper for a webextension that will be installed + // by some external process (e.g., Normandy) + expectExtension(id) { + return new ExternallyInstalledWrapper(this.currentScope, id); + }, + + failOnSchemaWarnings(warningsAsErrors = true) { + let prefName = "extensions.webextensions.warnings-as-errors"; + Services.prefs.setBoolPref(prefName, warningsAsErrors); + if (!warningsAsErrors) { + this.currentScope.registerCleanupFunction(() => { + Services.prefs.setBoolPref(prefName, true); + }); + } + }, + + /** @param {[origin: string, url: string, options: object]} args */ + async fetch(...args) { + return XPCShellContentUtils.fetch(...args); + }, + + /** + * Loads a content page into a hidden docShell. + * + * @param {string} url + * The URL to load. + * @param {object} [options = {}] + * @param {ExtensionWrapper} [options.extension] + * If passed, load the URL as an extension page for the given + * extension. + * @param {boolean} [options.remote] + * If true, load the URL in a content process. If false, load + * it in the parent process. + * @param {boolean} [options.remoteSubframes] + * If true, load cross-origin frames in separate content processes. + * This is ignored if |options.remote| is false. + * @param {string} [options.redirectUrl] + * An optional URL that the initial page is expected to + * redirect to. + * + * @returns {XPCShellContentUtils.ContentPage} + */ + loadContentPage(url, options) { + return XPCShellContentUtils.loadContentPage(url, options); + }, +}; diff --git a/toolkit/components/extensions/ExtensionsChild.cpp b/toolkit/components/extensions/ExtensionsChild.cpp new file mode 100644 index 0000000000..258c2ed1b7 --- /dev/null +++ b/toolkit/components/extensions/ExtensionsChild.cpp @@ -0,0 +1,73 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#include "mozilla/extensions/ExtensionsChild.h" +#include "mozilla/extensions/ExtensionsParent.h" + +#include "mozilla/ClearOnShutdown.h" +#include "mozilla/dom/ContentChild.h" +#include "mozilla/dom/InProcessChild.h" +#include "mozilla/dom/InProcessParent.h" +#include "mozilla/ipc/Endpoint.h" +#include "nsXULAppAPI.h" + +using namespace mozilla::dom; + +namespace mozilla { +namespace extensions { + +NS_IMPL_ISUPPORTS(ExtensionsChild, nsIObserver) + +/* static */ +ExtensionsChild& ExtensionsChild::Get() { + static RefPtr sInstance; + + if (MOZ_UNLIKELY(!sInstance)) { + sInstance = new ExtensionsChild(); + sInstance->Init(); + ClearOnShutdown(&sInstance); + } + return *sInstance; +} + +/* static */ +already_AddRefed ExtensionsChild::GetSingleton() { + return do_AddRef(&Get()); +} + +void ExtensionsChild::Init() { + if (XRE_IsContentProcess()) { + ContentChild::GetSingleton()->SendPExtensionsConstructor(this); + } else { + InProcessChild* ipChild = InProcessChild::Singleton(); + InProcessParent* ipParent = InProcessParent::Singleton(); + if (!ipChild || !ipParent) { + return; + } + + RefPtr parent = new ExtensionsParent(); + + ManagedEndpoint endpoint = + ipChild->OpenPExtensionsEndpoint(this); + ipParent->BindPExtensionsEndpoint(std::move(endpoint), parent); + } +} + +void ExtensionsChild::ActorDestroy(ActorDestroyReason aWhy) {} + +/* nsIObserver */ + +NS_IMETHODIMP ExtensionsChild::Observe(nsISupports*, const char* aTopic, + const char16_t*) { + // Since this class is created at startup by the Category Manager, it's + // expected to implement nsIObserver; however, we have nothing interesting + // to do here. + MOZ_ASSERT(strcmp(aTopic, "app-startup") == 0); + + return NS_OK; +} + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/ExtensionsChild.h b/toolkit/components/extensions/ExtensionsChild.h new file mode 100644 index 0000000000..4e7adcdd48 --- /dev/null +++ b/toolkit/components/extensions/ExtensionsChild.h @@ -0,0 +1,37 @@ +/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#ifndef mozilla_extensions_ExtensionsChild_h +#define mozilla_extensions_ExtensionsChild_h + +#include "mozilla/extensions/PExtensionsChild.h" +#include "nsISupportsImpl.h" + +namespace mozilla { +namespace extensions { + +class ExtensionsChild final : public nsIObserver, public PExtensionsChild { + public: + NS_DECL_ISUPPORTS + NS_DECL_NSIOBSERVER + + static already_AddRefed GetSingleton(); + + static ExtensionsChild& Get(); + + private: + ExtensionsChild() = default; + ~ExtensionsChild() = default; + + void Init(); + + protected: + void ActorDestroy(ActorDestroyReason aWhy) override; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ExtensionsChild_h diff --git a/toolkit/components/extensions/ExtensionsParent.cpp b/toolkit/components/extensions/ExtensionsParent.cpp new file mode 100644 index 0000000000..0e10af241f --- /dev/null +++ b/toolkit/components/extensions/ExtensionsParent.cpp @@ -0,0 +1,126 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#include "extIWebNavigation.h" +#include "mozilla/extensions/ExtensionsParent.h" +#include "mozilla/dom/BrowsingContext.h" +#include "mozilla/dom/ContentParent.h" +#include "mozilla/dom/ScriptSettings.h" +#include "mozilla/RefPtr.h" +#include "jsapi.h" +#include "js/PropertyAndElement.h" // JS_SetProperty +#include "nsImportModule.h" +#include "xpcpublic.h" + +namespace mozilla { +namespace extensions { + +ExtensionsParent::ExtensionsParent() {} +ExtensionsParent::~ExtensionsParent() {} + +extIWebNavigation* ExtensionsParent::WebNavigation() { + if (!mWebNavigation) { + mWebNavigation = do_ImportModule("resource://gre/modules/WebNavigation.jsm", + "WebNavigationManager"); + } + return mWebNavigation; +} + +void ExtensionsParent::ActorDestroy(ActorDestroyReason aWhy) {} + +static inline JS::Handle ToJSBoolean(bool aValue) { + return aValue ? JS::TrueHandleValue : JS::FalseHandleValue; +} + +JS::Value FrameTransitionDataToJSValue(const FrameTransitionData& aData) { + JS::Rooted ret(dom::RootingCx(), JS::UndefinedValue()); + { + dom::AutoJSAPI jsapi; + MOZ_ALWAYS_TRUE(jsapi.Init(xpc::PrivilegedJunkScope())); + JSContext* cx = jsapi.cx(); + + JS::Rooted obj(cx, JS_NewPlainObject(cx)); + if (obj && + JS_SetProperty(cx, obj, "forward_back", + ToJSBoolean(aData.forwardBack())) && + JS_SetProperty(cx, obj, "form_submit", + ToJSBoolean(aData.formSubmit())) && + JS_SetProperty(cx, obj, "reload", ToJSBoolean(aData.reload())) && + JS_SetProperty(cx, obj, "server_redirect", + ToJSBoolean(aData.serverRedirect())) && + JS_SetProperty(cx, obj, "client_redirect", + ToJSBoolean(aData.clientRedirect()))) { + ret.setObject(*obj); + } + } + return ret; +} + +ipc::IPCResult ExtensionsParent::RecvDocumentChange( + MaybeDiscardedBrowsingContext&& aBC, FrameTransitionData&& aTransitionData, + nsIURI* aLocation) { + if (aBC.IsNullOrDiscarded()) { + return IPC_OK(); + } + + JS::Rooted transitionData( + dom::RootingCx(), FrameTransitionDataToJSValue(aTransitionData)); + + WebNavigation()->OnDocumentChange(aBC.get(), transitionData, aLocation); + return IPC_OK(); +} + +ipc::IPCResult ExtensionsParent::RecvHistoryChange( + MaybeDiscardedBrowsingContext&& aBC, FrameTransitionData&& aTransitionData, + nsIURI* aLocation, bool aIsHistoryStateUpdated, + bool aIsReferenceFragmentUpdated) { + if (aBC.IsNullOrDiscarded()) { + return IPC_OK(); + } + + JS::Rooted transitionData( + dom::RootingCx(), FrameTransitionDataToJSValue(aTransitionData)); + + WebNavigation()->OnHistoryChange(aBC.get(), transitionData, aLocation, + aIsHistoryStateUpdated, + aIsReferenceFragmentUpdated); + return IPC_OK(); +} + +ipc::IPCResult ExtensionsParent::RecvStateChange( + MaybeDiscardedBrowsingContext&& aBC, nsIURI* aRequestURI, nsresult aStatus, + uint32_t aStateFlags) { + if (aBC.IsNullOrDiscarded()) { + return IPC_OK(); + } + + WebNavigation()->OnStateChange(aBC.get(), aRequestURI, aStatus, aStateFlags); + return IPC_OK(); +} + +ipc::IPCResult ExtensionsParent::RecvCreatedNavigationTarget( + MaybeDiscardedBrowsingContext&& aBC, + MaybeDiscardedBrowsingContext&& aSourceBC, const nsCString& aURL) { + if (aBC.IsNullOrDiscarded() || aSourceBC.IsNull()) { + return IPC_OK(); + } + + WebNavigation()->OnCreatedNavigationTarget( + aBC.get(), aSourceBC.GetMaybeDiscarded(), aURL); + return IPC_OK(); +} + +ipc::IPCResult ExtensionsParent::RecvDOMContentLoaded( + MaybeDiscardedBrowsingContext&& aBC, nsIURI* aDocumentURI) { + if (aBC.IsNullOrDiscarded()) { + return IPC_OK(); + } + + WebNavigation()->OnDOMContentLoaded(aBC.get(), aDocumentURI); + return IPC_OK(); +} + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/ExtensionsParent.h b/toolkit/components/extensions/ExtensionsParent.h new file mode 100644 index 0000000000..039e6c9093 --- /dev/null +++ b/toolkit/components/extensions/ExtensionsParent.h @@ -0,0 +1,58 @@ +/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#ifndef mozilla_extensions_ExtensionsParent_h +#define mozilla_extensions_ExtensionsParent_h + +#include "mozilla/extensions/PExtensionsParent.h" +#include "nsISupportsImpl.h" + +class extIWebNavigation; + +namespace mozilla { +namespace extensions { + +class ExtensionsParent final : public PExtensionsParent { + public: + NS_INLINE_DECL_REFCOUNTING(ExtensionsParent, final) + + ExtensionsParent(); + + ipc::IPCResult RecvDocumentChange(MaybeDiscardedBrowsingContext&& aBC, + FrameTransitionData&& aTransitionData, + nsIURI* aLocation); + + ipc::IPCResult RecvHistoryChange(MaybeDiscardedBrowsingContext&& aBC, + FrameTransitionData&& aTransitionData, + nsIURI* aLocation, + bool aIsHistoryStateUpdated, + bool aIsReferenceFragmentUpdated); + + ipc::IPCResult RecvStateChange(MaybeDiscardedBrowsingContext&& aBC, + nsIURI* aRequestURI, nsresult aStatus, + uint32_t aStateFlags); + + ipc::IPCResult RecvCreatedNavigationTarget( + MaybeDiscardedBrowsingContext&& aBC, + MaybeDiscardedBrowsingContext&& aSourceBC, const nsCString& aURI); + + ipc::IPCResult RecvDOMContentLoaded(MaybeDiscardedBrowsingContext&& aBC, + nsIURI* aDocumentURI); + + private: + ~ExtensionsParent(); + + extIWebNavigation* WebNavigation(); + + nsCOMPtr mWebNavigation; + + protected: + void ActorDestroy(ActorDestroyReason aWhy) override; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_ExtensionsParent_h diff --git a/toolkit/components/extensions/FindContent.sys.mjs b/toolkit/components/extensions/FindContent.sys.mjs new file mode 100644 index 0000000000..264d56f556 --- /dev/null +++ b/toolkit/components/extensions/FindContent.sys.mjs @@ -0,0 +1,250 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Finder: "resource://gre/modules/Finder.sys.mjs", + FinderHighlighter: "resource://gre/modules/FinderHighlighter.sys.mjs", + FinderIterator: "resource://gre/modules/FinderIterator.sys.mjs", +}); + +export class FindContent { + constructor(docShell) { + this.finder = new lazy.Finder(docShell); + } + + get iterator() { + if (!this._iterator) { + this._iterator = new lazy.FinderIterator(); + } + return this._iterator; + } + + get highlighter() { + if (!this._highlighter) { + this._highlighter = new lazy.FinderHighlighter(this.finder, true); + } + return this._highlighter; + } + + /** + * findRanges + * + * Performs a search which will cache found ranges in `iterator._previousRanges`. Cached + * data can then be used by `highlightResults`, `_collectRectData` and `_serializeRangeData`. + * + * @param {object} params - the params. + * @param {string} params.queryphrase - the text to search for. + * @param {boolean} params.caseSensitive - whether to use case sensitive matches. + * @param {boolean} params.includeRangeData - whether to collect and return range data. + * @param {boolean} params.matchDiacritics - whether diacritics must match. + * @param {boolean} params.searchString - whether to collect and return rect data. + * @param {boolean} params.entireWord - whether to match entire words. + * @param {boolean} params.includeRectData - collect and return rect data. + * + * @returns {object} that includes: + * {number} count - number of results found. + * {array} rangeData (if opted) - serialized representation of ranges found. + * {array} rectData (if opted) - rect data of ranges found. + */ + findRanges(params) { + return new Promise(resolve => { + let { + queryphrase, + caseSensitive, + entireWord, + includeRangeData, + includeRectData, + matchDiacritics, + } = params; + + this.iterator.reset(); + + // Cast `caseSensitive` and `entireWord` to boolean, otherwise _iterator.start will throw. + let iteratorPromise = this.iterator.start({ + word: queryphrase, + caseSensitive: !!caseSensitive, + entireWord: !!entireWord, + finder: this.finder, + listener: this.finder, + matchDiacritics: !!matchDiacritics, + useSubFrames: false, + }); + + iteratorPromise.then(() => { + let rangeData; + let rectData; + if (includeRangeData) { + rangeData = this._serializeRangeData(); + } + if (includeRectData) { + rectData = this._collectRectData(); + } + + resolve({ + count: this.iterator._previousRanges.length, + rangeData, + rectData, + }); + }); + }); + } + + /** + * _serializeRangeData + * + * Optionally returned by `findRanges`. + * Collects DOM data from ranges found on the most recent search made by `findRanges` + * and encodes it into a serializable form. Useful to extensions for custom UI presentation + * of search results, eg, getting surrounding context of results. + * + * @returns {Array} - serializable range data. + */ + _serializeRangeData() { + let ranges = this.iterator._previousRanges; + + let rangeData = []; + let nodeCountWin = 0; + let lastDoc; + let walker; + let node; + + for (let range of ranges) { + let startContainer = range.startContainer; + let doc = startContainer.ownerDocument; + + if (lastDoc !== doc) { + walker = doc.createTreeWalker( + doc, + doc.defaultView.NodeFilter.SHOW_TEXT, + null, + false + ); + // Get first node. + node = walker.nextNode(); + // Reset node count. + nodeCountWin = 0; + } + lastDoc = doc; + + // The framePos will be set by the parent process later. + let data = { framePos: 0, text: range.toString() }; + rangeData.push(data); + + if (node != range.startContainer) { + node = walker.nextNode(); + while (node) { + nodeCountWin++; + if (node == range.startContainer) { + break; + } + node = walker.nextNode(); + } + } + data.startTextNodePos = nodeCountWin; + data.startOffset = range.startOffset; + + if (range.startContainer != range.endContainer) { + node = walker.nextNode(); + while (node) { + nodeCountWin++; + if (node == range.endContainer) { + break; + } + node = walker.nextNode(); + } + } + data.endTextNodePos = nodeCountWin; + data.endOffset = range.endOffset; + } + + return rangeData; + } + + /** + * _collectRectData + * + * Optionally returned by `findRanges`. + * Collects rect data of ranges found by most recent search made by `findRanges`. + * Useful to extensions for custom highlighting of search results. + * + * @returns {Array} rectData - serializable rect data. + */ + _collectRectData() { + let rectData = []; + + let ranges = this.iterator._previousRanges; + for (let range of ranges) { + let rectsAndTexts = this.highlighter._getRangeRectsAndTexts(range); + rectData.push({ text: range.toString(), rectsAndTexts }); + } + + return rectData; + } + + /** + * highlightResults + * + * Highlights range(s) found in previous browser.find.find. + * + * @param {object} params - may contain any of the following properties: + * all of which are optional: + * {number} rangeIndex - + * Found range to be highlighted held in API's ranges array for the tabId. + * Default highlights all ranges. + * {number} tabId - Tab to highlight. Defaults to the active tab. + * {boolean} noScroll - Don't scroll to highlighted item. + * + * @returns {string} - a string describing the resulting status of the highlighting, + * which will be used as criteria for resolving or rejecting the promise. + * This can be: + * "Success" - Highlighting succeeded. + * "OutOfRange" - The index supplied was out of range. + * "NoResults" - There were no search results to highlight. + */ + highlightResults(params) { + let { rangeIndex, noScroll } = params; + + this.highlighter.highlight(false); + let ranges = this.iterator._previousRanges; + + let status = "Success"; + + if (ranges.length) { + if (typeof rangeIndex == "number") { + if (rangeIndex < ranges.length) { + let foundRange = ranges[rangeIndex]; + this.highlighter.highlightRange(foundRange); + + if (!noScroll) { + let node = foundRange.startContainer; + let editableNode = this.highlighter._getEditableNode(node); + let controller = editableNode + ? editableNode.editor.selectionController + : this.finder._getSelectionController(node.ownerGlobal); + + controller.scrollSelectionIntoView( + controller.SELECTION_FIND, + controller.SELECTION_ON, + controller.SCROLL_CENTER_VERTICALLY + ); + } + } else { + status = "OutOfRange"; + } + } else { + for (let range of ranges) { + this.highlighter.highlightRange(range); + } + } + } else { + status = "NoResults"; + } + + return status; + } +} diff --git a/toolkit/components/extensions/MatchGlob.h b/toolkit/components/extensions/MatchGlob.h new file mode 100644 index 0000000000..ad31759aaf --- /dev/null +++ b/toolkit/components/extensions/MatchGlob.h @@ -0,0 +1,113 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#ifndef mozilla_extensions_MatchGlob_h +#define mozilla_extensions_MatchGlob_h + +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/MatchGlobBinding.h" + +#include "jspubtd.h" +#include "js/RootingAPI.h" + +#include "mozilla/RustRegex.h" +#include "nsCOMPtr.h" +#include "nsCycleCollectionParticipant.h" +#include "nsISupports.h" +#include "nsWrapperCache.h" + +namespace mozilla { +namespace extensions { + +class MatchPattern; + +class MatchGlobCore final { + public: + NS_INLINE_DECL_THREADSAFE_REFCOUNTING(MatchGlobCore) + + MatchGlobCore(const nsACString& aGlob, bool aAllowQuestion, bool aIsPathGlob, + ErrorResult& aRv); + + bool Matches(const nsACString& aString) const; + + bool IsWildcard() const { return mIsPrefix && mPathLiteral.IsEmpty(); } + + void GetGlob(nsACString& aGlob) const { aGlob = mGlob; } + + private: + ~MatchGlobCore() = default; + + // The original glob string that this glob object represents. + const nsCString mGlob; + + // The literal path string to match against. If this contains a non-void + // value, the glob matches against this exact literal string, rather than + // performng a pattern match. If mIsPrefix is true, the literal must appear + // at the start of the matched string. If it is false, the the literal must + // be exactly equal to the matched string. + nsCString mPathLiteral; + bool mIsPrefix = false; + + // The regular expression object which is equivalent to this glob pattern. + // Used for matching if, and only if, mPathLiteral is non-void. + RustRegex mRegExp; +}; + +class MatchGlob final : public nsISupports, public nsWrapperCache { + public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(MatchGlob) + + static already_AddRefed Constructor(dom::GlobalObject& aGlobal, + const nsACString& aGlob, + bool aAllowQuestion, + ErrorResult& aRv); + + explicit MatchGlob(nsISupports* aParent, + already_AddRefed aCore) + : mParent(aParent), mCore(std::move(aCore)) {} + + bool Matches(const nsACString& aString) const { + return Core()->Matches(aString); + } + + bool IsWildcard() const { return Core()->IsWildcard(); } + + void GetGlob(nsACString& aGlob) const { Core()->GetGlob(aGlob); } + + MatchGlobCore* Core() const { return mCore; } + + nsISupports* GetParentObject() const { return mParent; } + + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle aGivenProto) override; + + private: + ~MatchGlob() = default; + + nsCOMPtr mParent; + + RefPtr mCore; +}; + +class MatchGlobSet final : public CopyableTArray> { + public: + // Note: We can't use the nsTArray constructors directly, since the static + // analyzer doesn't handle their MOZ_IMPLICIT annotations correctly. + MatchGlobSet() = default; + explicit MatchGlobSet(size_type aCapacity) : CopyableTArray(aCapacity) {} + explicit MatchGlobSet(const nsTArray& aOther) : CopyableTArray(aOther) {} + MOZ_IMPLICIT MatchGlobSet(nsTArray&& aOther) + : CopyableTArray(std::move(aOther)) {} + MOZ_IMPLICIT MatchGlobSet(std::initializer_list> aIL) + : CopyableTArray(aIL) {} + + bool Matches(const nsACString& aValue) const; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_MatchGlob_h diff --git a/toolkit/components/extensions/MatchPattern.cpp b/toolkit/components/extensions/MatchPattern.cpp new file mode 100644 index 0000000000..448399bd65 --- /dev/null +++ b/toolkit/components/extensions/MatchPattern.cpp @@ -0,0 +1,777 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#include "mozilla/extensions/MatchPattern.h" +#include "mozilla/extensions/MatchGlob.h" + +#include "js/RegExp.h" // JS::NewUCRegExpObject, JS::ExecuteRegExpNoStatics +#include "mozilla/dom/ScriptSettings.h" +#include "mozilla/ErrorResult.h" +#include "mozilla/HoldDropJSObjects.h" +#include "mozilla/Unused.h" + +#include "nsGkAtoms.h" +#include "nsIProtocolHandler.h" +#include "nsIURL.h" +#include "nsNetUtil.h" + +namespace mozilla { +namespace extensions { + +using namespace mozilla::dom; + +/***************************************************************************** + * AtomSet + *****************************************************************************/ + +template +static AtomSet::ArrayType AtomSetFromRange(Range&& aRange, + AsAtom&& aTransform) { + AtomSet::ArrayType atoms; + atoms.SetCapacity(RangeSize(aRange)); + std::transform(aRange.begin(), aRange.end(), MakeBackInserter(atoms), + std::forward(aTransform)); + + atoms.Sort(); + + nsAtom* prev = nullptr; + atoms.RemoveElementsBy([&prev](const RefPtr& aAtom) { + bool remove = aAtom == prev; + prev = aAtom; + return remove; + }); + + atoms.Compact(); + return atoms; +} + +AtomSet::AtomSet(const nsTArray& aElems) + : mElems(AtomSetFromRange( + aElems, [](const nsString& elem) { return NS_Atomize(elem); })) {} + +AtomSet::AtomSet(std::initializer_list aIL) + : mElems(AtomSetFromRange(aIL, [](nsAtom* elem) { return elem; })) {} + +bool AtomSet::Intersects(const AtomSet& aOther) const { + for (const auto& atom : *this) { + if (aOther.Contains(atom)) { + return true; + } + } + for (const auto& atom : aOther) { + if (Contains(atom)) { + return true; + } + } + return false; +} + +/***************************************************************************** + * URLInfo + *****************************************************************************/ + +nsAtom* URLInfo::Scheme() const { + if (!mScheme) { + nsCString scheme; + if (NS_SUCCEEDED(mURI->GetScheme(scheme))) { + mScheme = NS_AtomizeMainThread(NS_ConvertASCIItoUTF16(scheme)); + } + } + return mScheme; +} + +const nsCString& URLInfo::Host() const { + if (mHost.IsVoid()) { + Unused << mURI->GetHost(mHost); + } + return mHost; +} + +const nsAtom* URLInfo::HostAtom() const { + if (!mHostAtom) { + mHostAtom = NS_Atomize(Host()); + } + return mHostAtom; +} + +const nsCString& URLInfo::FilePath() const { + if (mFilePath.IsEmpty()) { + nsCOMPtr url = do_QueryInterface(mURI); + if (!url || NS_FAILED(url->GetFilePath(mFilePath))) { + mFilePath = Path(); + } + } + return mFilePath; +} + +const nsCString& URLInfo::Path() const { + if (mPath.IsEmpty()) { + if (NS_FAILED(URINoRef()->GetPathQueryRef(mPath))) { + mPath.Truncate(); + } + } + return mPath; +} + +const nsCString& URLInfo::CSpec() const { + if (mCSpec.IsEmpty()) { + Unused << URINoRef()->GetSpec(mCSpec); + } + return mCSpec; +} + +const nsString& URLInfo::Spec() const { + if (mSpec.IsEmpty()) { + AppendUTF8toUTF16(CSpec(), mSpec); + } + return mSpec; +} + +nsIURI* URLInfo::URINoRef() const { + if (!mURINoRef) { + if (NS_FAILED(NS_GetURIWithoutRef(mURI, getter_AddRefs(mURINoRef)))) { + mURINoRef = mURI; + } + } + return mURINoRef; +} + +bool URLInfo::InheritsPrincipal() const { + if (!mInheritsPrincipal.isSome()) { + // For our purposes, about:blank and about:srcdoc are treated as URIs that + // inherit principals. + bool inherits = Spec().EqualsLiteral("about:blank") || + Spec().EqualsLiteral("about:srcdoc"); + + if (!inherits) { + nsresult rv = NS_URIChainHasFlags( + mURI, nsIProtocolHandler::URI_INHERITS_SECURITY_CONTEXT, &inherits); + Unused << NS_WARN_IF(NS_FAILED(rv)); + } + + mInheritsPrincipal.emplace(inherits); + } + return mInheritsPrincipal.ref(); +} + +/***************************************************************************** + * CookieInfo + *****************************************************************************/ + +bool CookieInfo::IsDomain() const { + if (mIsDomain.isNothing()) { + mIsDomain.emplace(false); + MOZ_ALWAYS_SUCCEEDS(mCookie->GetIsDomain(mIsDomain.ptr())); + } + return mIsDomain.ref(); +} + +bool CookieInfo::IsSecure() const { + if (mIsSecure.isNothing()) { + mIsSecure.emplace(false); + MOZ_ALWAYS_SUCCEEDS(mCookie->GetIsSecure(mIsSecure.ptr())); + } + return mIsSecure.ref(); +} + +const nsCString& CookieInfo::Host() const { + if (mHost.IsEmpty()) { + MOZ_ALWAYS_SUCCEEDS(mCookie->GetHost(mHost)); + } + return mHost; +} + +const nsCString& CookieInfo::RawHost() const { + if (mRawHost.IsEmpty()) { + MOZ_ALWAYS_SUCCEEDS(mCookie->GetRawHost(mRawHost)); + } + return mRawHost; +} + +/***************************************************************************** + * MatchPatternCore + *****************************************************************************/ + +#define DEFINE_STATIC_ATOM_SET(name, ...) \ + static already_AddRefed name() { \ + MOZ_ASSERT(NS_IsMainThread()); \ + static StaticRefPtr sAtomSet; \ + RefPtr atomSet = sAtomSet; \ + if (!atomSet) { \ + atomSet = sAtomSet = new AtomSet{__VA_ARGS__}; \ + ClearOnShutdown(&sAtomSet); \ + } \ + return atomSet.forget(); \ + } + +DEFINE_STATIC_ATOM_SET(PermittedSchemes, nsGkAtoms::http, nsGkAtoms::https, + nsGkAtoms::ws, nsGkAtoms::wss, nsGkAtoms::file, + nsGkAtoms::ftp, nsGkAtoms::data); + +// Known schemes that are followed by "://" instead of ":". +DEFINE_STATIC_ATOM_SET(HostLocatorSchemes, nsGkAtoms::http, nsGkAtoms::https, + nsGkAtoms::ws, nsGkAtoms::wss, nsGkAtoms::file, + nsGkAtoms::ftp, nsGkAtoms::moz_extension, + nsGkAtoms::chrome, nsGkAtoms::resource, nsGkAtoms::moz, + nsGkAtoms::moz_icon, nsGkAtoms::moz_gio); + +DEFINE_STATIC_ATOM_SET(WildcardSchemes, nsGkAtoms::http, nsGkAtoms::https, + nsGkAtoms::ws, nsGkAtoms::wss); + +#undef DEFINE_STATIC_ATOM_SET + +MatchPatternCore::MatchPatternCore(const nsAString& aPattern, bool aIgnorePath, + bool aRestrictSchemes, ErrorResult& aRv) { + MOZ_ASSERT(NS_IsMainThread()); + RefPtr permittedSchemes = PermittedSchemes(); + + mPattern = aPattern; + + if (aPattern.EqualsLiteral("")) { + mSchemes = permittedSchemes; + mMatchSubdomain = true; + return; + } + + // The portion of the URL we're currently examining. + uint32_t offset = 0; + auto tail = Substring(aPattern, offset); + + /*************************************************************************** + * Scheme + ***************************************************************************/ + int32_t index = aPattern.FindChar(':'); + if (index <= 0) { + aRv.Throw(NS_ERROR_INVALID_ARG); + return; + } + + RefPtr scheme = NS_AtomizeMainThread(StringHead(aPattern, index)); + bool requireHostLocatorScheme = true; + if (scheme == nsGkAtoms::_asterisk) { + mSchemes = WildcardSchemes(); + } else if (!aRestrictSchemes || permittedSchemes->Contains(scheme) || + scheme == nsGkAtoms::moz_extension) { + RefPtr hostLocatorSchemes = HostLocatorSchemes(); + mSchemes = new AtomSet({scheme}); + requireHostLocatorScheme = hostLocatorSchemes->Contains(scheme); + } else { + aRv.Throw(NS_ERROR_INVALID_ARG); + return; + } + + /*************************************************************************** + * Host + ***************************************************************************/ + offset = index + 1; + tail.Rebind(aPattern, offset); + + if (!requireHostLocatorScheme) { + // Unrecognized schemes and some schemes such as about: and data: URIs + // don't have hosts, so just match on the path. + // And so, ignorePath doesn't make sense for these matchers. + aIgnorePath = false; + } else { + if (!StringHead(tail, 2).EqualsLiteral("//")) { + aRv.Throw(NS_ERROR_INVALID_ARG); + return; + } + + offset += 2; + tail.Rebind(aPattern, offset); + index = tail.FindChar('/'); + if (index < 0) { + index = tail.Length(); + } + + auto host = StringHead(tail, index); + if (host.IsEmpty() && scheme != nsGkAtoms::file) { + aRv.Throw(NS_ERROR_INVALID_ARG); + return; + } + + offset += index; + tail.Rebind(aPattern, offset); + + if (host.EqualsLiteral("*")) { + mMatchSubdomain = true; + } else if (StringHead(host, 2).EqualsLiteral("*.")) { + CopyUTF16toUTF8(Substring(host, 2), mDomain); + mMatchSubdomain = true; + } else if (host.Length() > 1 && host[0] == '[' && + host[host.Length() - 1] == ']') { + // This is an IPv6 literal, we drop the enclosing `[]` to be + // consistent with nsIURI. + CopyUTF16toUTF8(Substring(host, 1, host.Length() - 2), mDomain); + } else { + CopyUTF16toUTF8(host, mDomain); + } + } + + /*************************************************************************** + * Path + ***************************************************************************/ + if (aIgnorePath) { + mPattern.Truncate(offset); + mPattern.AppendLiteral("/*"); + return; + } + + NS_ConvertUTF16toUTF8 path(tail); + if (path.IsEmpty()) { + aRv.Throw(NS_ERROR_INVALID_ARG); + return; + } + + // Anything matched against one of the hosts in hostLocatorSchemes is expected + // to have a path starting with "/". Pass isPathGlob=true in these cases to + // ensure that MatchGlobCore treats "/*" paths as a wildcard (IsWildcard()). + bool isPathGlob = requireHostLocatorScheme; + mPath = new MatchGlobCore(path, false, isPathGlob, aRv); +} + +bool MatchPatternCore::MatchesAllWebUrls() const { + // Returns true if the match pattern matches any http(s) URL, i.e.: + // - [""] + // - ["*://*/*"] + return (mSchemes->Contains(nsGkAtoms::http) && + MatchesAllUrlsWithScheme(nsGkAtoms::https)); +} + +bool MatchPatternCore::MatchesAllUrlsWithScheme(const nsAtom* scheme) const { + return (mSchemes->Contains(scheme) && DomainIsWildcard() && + (!mPath || mPath->IsWildcard())); +} + +bool MatchPatternCore::MatchesDomain(const nsACString& aDomain) const { + if (DomainIsWildcard() || mDomain == aDomain) { + return true; + } + + if (mMatchSubdomain) { + int64_t offset = (int64_t)aDomain.Length() - mDomain.Length(); + if (offset > 0 && aDomain[offset - 1] == '.' && + Substring(aDomain, offset) == mDomain) { + return true; + } + } + + return false; +} + +bool MatchPatternCore::Matches(const nsAString& aURL, bool aExplicit, + ErrorResult& aRv) const { + nsCOMPtr uri; + nsresult rv = NS_NewURI(getter_AddRefs(uri), aURL); + if (NS_FAILED(rv)) { + aRv.Throw(rv); + return false; + } + + return Matches(uri.get(), aExplicit); +} + +bool MatchPatternCore::Matches(const URLInfo& aURL, bool aExplicit) const { + if (aExplicit && mMatchSubdomain) { + return false; + } + + if (!mSchemes->Contains(aURL.Scheme())) { + return false; + } + + if (!MatchesDomain(aURL.Host())) { + return false; + } + + if (mPath && !mPath->IsWildcard() && !mPath->Matches(aURL.Path())) { + return false; + } + + return true; +} + +bool MatchPatternCore::MatchesCookie(const CookieInfo& aCookie) const { + if (!mSchemes->Contains(nsGkAtoms::https) && + (aCookie.IsSecure() || !mSchemes->Contains(nsGkAtoms::http))) { + return false; + } + + if (MatchesDomain(aCookie.RawHost())) { + return true; + } + + if (!aCookie.IsDomain()) { + return false; + } + + // Things get tricker for domain cookies. The extension needs to be able + // to read any cookies that could be read by any host it has permissions + // for. This means that our normal host matching checks won't work, + // since the pattern "*://*.foo.example.com/" doesn't match ".example.com", + // but it does match "bar.foo.example.com", which can read cookies + // with the domain ".example.com". + // + // So, instead, we need to manually check our filters, and accept any + // with hosts that end with our cookie's host. + + auto& host = aCookie.Host(); + return StringTail(mDomain, host.Length()) == host; +} + +bool MatchPatternCore::SubsumesDomain(const MatchPatternCore& aPattern) const { + if (!mMatchSubdomain && aPattern.mMatchSubdomain && + aPattern.mDomain == mDomain) { + return false; + } + + return MatchesDomain(aPattern.mDomain); +} + +bool MatchPatternCore::Subsumes(const MatchPatternCore& aPattern) const { + for (auto& scheme : *aPattern.mSchemes) { + if (!mSchemes->Contains(scheme)) { + return false; + } + } + + return SubsumesDomain(aPattern); +} + +bool MatchPatternCore::Overlaps(const MatchPatternCore& aPattern) const { + if (!mSchemes->Intersects(*aPattern.mSchemes)) { + return false; + } + + return SubsumesDomain(aPattern) || aPattern.SubsumesDomain(*this); +} + +/***************************************************************************** + * MatchPattern + *****************************************************************************/ + +/* static */ +already_AddRefed MatchPattern::Constructor( + dom::GlobalObject& aGlobal, const nsAString& aPattern, + const MatchPatternOptions& aOptions, ErrorResult& aRv) { + RefPtr pattern = new MatchPattern( + aGlobal.GetAsSupports(), + MakeAndAddRef(aPattern, aOptions.mIgnorePath, + aOptions.mRestrictSchemes, aRv)); + if (aRv.Failed()) { + return nullptr; + } + return pattern.forget(); +} + +JSObject* MatchPattern::WrapObject(JSContext* aCx, + JS::Handle aGivenProto) { + return MatchPattern_Binding::Wrap(aCx, this, aGivenProto); +} + +/* static */ +bool MatchPattern::MatchesAllURLs(const URLInfo& aURL) { + RefPtr permittedSchemes = PermittedSchemes(); + return permittedSchemes->Contains(aURL.Scheme()); +} + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(MatchPattern, mParent) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(MatchPattern) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(MatchPattern) +NS_IMPL_CYCLE_COLLECTING_RELEASE(MatchPattern) + +bool MatchPatternSetCore::Matches(const nsAString& aURL, bool aExplicit, + ErrorResult& aRv) const { + nsCOMPtr uri; + nsresult rv = NS_NewURI(getter_AddRefs(uri), aURL); + if (NS_FAILED(rv)) { + aRv.Throw(rv); + return false; + } + + return Matches(uri.get(), aExplicit); +} + +bool MatchPatternSetCore::Matches(const URLInfo& aURL, bool aExplicit) const { + for (const auto& pattern : mPatterns) { + if (pattern->Matches(aURL, aExplicit)) { + return true; + } + } + return false; +} + +bool MatchPatternSetCore::MatchesAllWebUrls() const { + // Returns true if the match pattern matches any http(s) URL, i.e.: + // - [""] + // - ["*://*/*"] + // - ["https://*/*", "http://*/*"] + bool hasHttp = false; + bool hasHttps = false; + for (const auto& pattern : mPatterns) { + if (!hasHttp && pattern->MatchesAllUrlsWithScheme(nsGkAtoms::http)) { + hasHttp = true; + } + if (!hasHttps && pattern->MatchesAllUrlsWithScheme(nsGkAtoms::https)) { + hasHttps = true; + } + if (hasHttp && hasHttps) { + return true; + } + } + return false; +} + +bool MatchPatternSetCore::MatchesCookie(const CookieInfo& aCookie) const { + for (const auto& pattern : mPatterns) { + if (pattern->MatchesCookie(aCookie)) { + return true; + } + } + return false; +} + +bool MatchPatternSetCore::Subsumes(const MatchPatternCore& aPattern) const { + // Note: the implementation below assumes that a pattern can only be subsumed + // if it is fully contained within another pattern. Logically, this is an + // incorrect assumption: "*://example.com/" matches multiple schemes, and is + // equivalent to a MatchPatternSet that lists all schemes explicitly. + // TODO bug 1856380: account for all patterns if aPattern has a wildcard + // scheme (such as when aPattern.MatchesAllWebUrls() is true). + for (const auto& pattern : mPatterns) { + if (pattern->Subsumes(aPattern)) { + return true; + } + } + return false; +} + +bool MatchPatternSetCore::SubsumesDomain( + const MatchPatternCore& aPattern) const { + for (const auto& pattern : mPatterns) { + if (pattern->SubsumesDomain(aPattern)) { + return true; + } + } + return false; +} + +bool MatchPatternSetCore::Overlaps( + const MatchPatternSetCore& aPatternSet) const { + for (const auto& pattern : aPatternSet.mPatterns) { + if (Overlaps(*pattern)) { + return true; + } + } + return false; +} + +bool MatchPatternSetCore::Overlaps(const MatchPatternCore& aPattern) const { + for (const auto& pattern : mPatterns) { + if (pattern->Overlaps(aPattern)) { + return true; + } + } + return false; +} + +bool MatchPatternSetCore::OverlapsAll( + const MatchPatternSetCore& aPatternSet) const { + for (const auto& pattern : aPatternSet.mPatterns) { + if (!Overlaps(*pattern)) { + return false; + } + } + return aPatternSet.mPatterns.Length() > 0; +} + +/***************************************************************************** + * MatchPatternSet + *****************************************************************************/ + +/* static */ +already_AddRefed MatchPatternSet::Constructor( + dom::GlobalObject& aGlobal, + const nsTArray& aPatterns, + const MatchPatternOptions& aOptions, ErrorResult& aRv) { + MatchPatternSetCore::ArrayType patterns; + + for (auto& elem : aPatterns) { + if (elem.IsMatchPattern()) { + patterns.AppendElement(elem.GetAsMatchPattern()->Core()); + } else { + RefPtr pattern = + new MatchPatternCore(elem.GetAsString(), aOptions.mIgnorePath, + aOptions.mRestrictSchemes, aRv); + + if (aRv.Failed()) { + return nullptr; + } + patterns.AppendElement(std::move(pattern)); + } + } + + RefPtr patternSet = new MatchPatternSet( + aGlobal.GetAsSupports(), + do_AddRef(new MatchPatternSetCore(std::move(patterns)))); + return patternSet.forget(); +} + +void MatchPatternSet::GetPatterns(ArrayType& aPatterns) { + if (!mPatternsCache) { + mPatternsCache.emplace(Core()->mPatterns.Length()); + for (auto& elem : Core()->mPatterns) { + mPatternsCache->AppendElement(new MatchPattern(this, do_AddRef(elem))); + } + } + aPatterns.AppendElements(*mPatternsCache); +} + +JSObject* MatchPatternSet::WrapObject(JSContext* aCx, + JS::Handle aGivenProto) { + return MatchPatternSet_Binding::Wrap(aCx, this, aGivenProto); +} + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(MatchPatternSet, mPatternsCache, mParent) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(MatchPatternSet) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(MatchPatternSet) +NS_IMPL_CYCLE_COLLECTING_RELEASE(MatchPatternSet) + +/***************************************************************************** + * MatchGlobCore + *****************************************************************************/ + +MatchGlobCore::MatchGlobCore(const nsACString& aGlob, bool aAllowQuestion, + bool aIsPathGlob, ErrorResult& aRv) + : mGlob(aGlob) { + // Check for a literal match with no glob metacharacters. + auto index = mGlob.FindCharInSet(aAllowQuestion ? "*?" : "*"); + if (index < 0) { + mPathLiteral = mGlob; + return; + } + + // Check for a prefix match, where the only glob metacharacter is a "*" + // at the end of the string (or a sequence of it). + for (int32_t i = mGlob.Length() - 1; i >= index && mGlob[i] == '*'; --i) { + if (i == index) { + mPathLiteral = StringHead(mGlob, index); + if (aIsPathGlob && mPathLiteral.EqualsLiteral("/")) { + // Ensure that IsWildcard() correctly treats us as a wildcard. + mPathLiteral.Truncate(); + } + mIsPrefix = true; + return; + } + } + + // Fall back to the regexp slow path. + constexpr auto metaChars = ".+*?^${}()|[]\\"_ns; + + nsAutoCString escaped; + escaped.Append('^'); + + // For any continuous string of * (and ? if aAllowQuestion) wildcards, only + // emit the first *, later ones are redundant, and can hang regex matching. + bool emittedFirstStar = false; + + for (uint32_t i = 0; i < mGlob.Length(); i++) { + auto c = mGlob[i]; + if (c == '*') { + if (!emittedFirstStar) { + escaped.AppendLiteral(".*"); + emittedFirstStar = true; + } + } else if (c == '?' && aAllowQuestion) { + escaped.Append('.'); + } else { + if (metaChars.Contains(c)) { + escaped.Append('\\'); + } + escaped.Append(c); + + // String of wildcards broken by a non-wildcard char, reset tracking flag. + emittedFirstStar = false; + } + } + + escaped.Append('$'); + + mRegExp = RustRegex(escaped); + if (!mRegExp) { + aRv.ThrowTypeError("failed to compile regex for glob"); + } +} + +bool MatchGlobCore::Matches(const nsACString& aString) const { + if (mRegExp) { + return mRegExp.IsMatch(aString); + } + + if (mIsPrefix) { + return mPathLiteral == StringHead(aString, mPathLiteral.Length()); + } + + return mPathLiteral == aString; +} + +/***************************************************************************** + * MatchGlob + *****************************************************************************/ + +/* static */ +already_AddRefed MatchGlob::Constructor(dom::GlobalObject& aGlobal, + const nsACString& aGlob, + bool aAllowQuestion, + ErrorResult& aRv) { + RefPtr glob = new MatchGlob( + aGlobal.GetAsSupports(), + MakeAndAddRef(aGlob, aAllowQuestion, false, aRv)); + if (aRv.Failed()) { + return nullptr; + } + return glob.forget(); +} + +JSObject* MatchGlob::WrapObject(JSContext* aCx, + JS::Handle aGivenProto) { + return MatchGlob_Binding::Wrap(aCx, this, aGivenProto); +} + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(MatchGlob, mParent) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(MatchGlob) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(MatchGlob) +NS_IMPL_CYCLE_COLLECTING_RELEASE(MatchGlob) + +/***************************************************************************** + * MatchGlobSet + *****************************************************************************/ + +bool MatchGlobSet::Matches(const nsACString& aValue) const { + for (auto& glob : *this) { + if (glob->Matches(aValue)) { + return true; + } + } + return false; +} + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/MatchPattern.h b/toolkit/components/extensions/MatchPattern.h new file mode 100644 index 0000000000..ebfd1c62a1 --- /dev/null +++ b/toolkit/components/extensions/MatchPattern.h @@ -0,0 +1,403 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#ifndef mozilla_extensions_MatchPattern_h +#define mozilla_extensions_MatchPattern_h + +#include + +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/MatchPatternBinding.h" +#include "mozilla/extensions/MatchGlob.h" + +#include "jspubtd.h" + +#include "mozilla/ClearOnShutdown.h" +#include "mozilla/Likely.h" +#include "mozilla/Maybe.h" +#include "mozilla/RefCounted.h" +#include "nsCOMPtr.h" +#include "nsCycleCollectionParticipant.h" +#include "nsTArray.h" +#include "nsAtom.h" +#include "nsICookie.h" +#include "nsISupports.h" +#include "nsIURI.h" +#include "nsWrapperCache.h" + +namespace mozilla { +namespace extensions { + +using dom::MatchPatternOptions; + +// A sorted, immutable, binary-search-backed set of atoms, optimized for +// frequent lookups. +class AtomSet final { + public: + using ArrayType = AutoTArray, 1>; + + NS_INLINE_DECL_THREADSAFE_REFCOUNTING(AtomSet) + + explicit AtomSet(const nsTArray& aElems); + + MOZ_IMPLICIT AtomSet(std::initializer_list aIL); + + bool Contains(const nsAString& elem) const { + RefPtr atom = NS_Atomize(elem); + return Contains(atom); + } + + bool Contains(const nsACString& aElem) const { + RefPtr atom = NS_Atomize(aElem); + return Contains(atom); + } + + bool Contains(const nsAtom* aAtom) const { + return mElems.ContainsSorted(aAtom); + } + + bool Intersects(const AtomSet& aOther) const; + + void Get(nsTArray& aResult) const { + aResult.SetCapacity(mElems.Length()); + + for (const auto& atom : mElems) { + aResult.AppendElement(nsDependentAtomString(atom)); + } + } + + auto begin() const -> decltype(std::declval().begin()) { + return mElems.begin(); + } + + auto end() const -> decltype(std::declval().end()) { + return mElems.end(); + } + + private: + ~AtomSet() = default; + + const ArrayType mElems; +}; + +// A helper class to lazily retrieve, transcode, and atomize certain URI +// properties the first time they're used, and cache the results, so that they +// can be used across multiple match operations. +class URLInfo final { + public: + MOZ_IMPLICIT URLInfo(nsIURI* aURI) : mURI(aURI) { mHost.SetIsVoid(true); } + + URLInfo(nsIURI* aURI, bool aNoRef) : URLInfo(aURI) { + if (aNoRef) { + mURINoRef = mURI; + } + } + + URLInfo(const URLInfo& aOther) : URLInfo(aOther.mURI.get()) {} + + nsIURI* URI() const { return mURI; } + + nsAtom* Scheme() const; + const nsCString& Host() const; + const nsAtom* HostAtom() const; + const nsCString& Path() const; + const nsCString& FilePath() const; + const nsString& Spec() const; + const nsCString& CSpec() const; + + bool InheritsPrincipal() const; + + private: + nsIURI* URINoRef() const; + + nsCOMPtr mURI; + mutable nsCOMPtr mURINoRef; + + mutable RefPtr mScheme; + mutable nsCString mHost; + mutable RefPtr mHostAtom; + + mutable nsCString mPath; + mutable nsCString mFilePath; + mutable nsString mSpec; + mutable nsCString mCSpec; + + mutable Maybe mInheritsPrincipal; +}; + +// Similar to URLInfo, but for cookies. +class MOZ_STACK_CLASS CookieInfo final { + public: + MOZ_IMPLICIT CookieInfo(nsICookie* aCookie) : mCookie(aCookie) {} + + bool IsSecure() const; + bool IsDomain() const; + + const nsCString& Host() const; + const nsCString& RawHost() const; + + private: + nsCOMPtr mCookie; + + mutable Maybe mIsSecure; + mutable Maybe mIsDomain; + + mutable nsCString mHost; + mutable nsCString mRawHost; +}; + +class MatchPatternCore final { + public: + NS_INLINE_DECL_THREADSAFE_REFCOUNTING(MatchPatternCore) + + // NOTE: Must be constructed on the main thread! + MatchPatternCore(const nsAString& aPattern, bool aIgnorePath, + bool aRestrictSchemes, ErrorResult& aRv); + + bool Matches(const nsAString& aURL, bool aExplicit, ErrorResult& aRv) const; + + bool Matches(const URLInfo& aURL, bool aExplicit = false) const; + + bool MatchesAllWebUrls() const; + // Helper for MatchPatternSetCore::MatchesAllWebUrls: + bool MatchesAllUrlsWithScheme(const nsAtom* aScheme) const; + + bool MatchesCookie(const CookieInfo& aCookie) const; + + bool MatchesDomain(const nsACString& aDomain) const; + + bool Subsumes(const MatchPatternCore& aPattern) const; + + bool SubsumesDomain(const MatchPatternCore& aPattern) const; + + bool Overlaps(const MatchPatternCore& aPattern) const; + + bool DomainIsWildcard() const { return mMatchSubdomain && mDomain.IsEmpty(); } + + void GetPattern(nsAString& aPattern) const { aPattern = mPattern; } + + private: + ~MatchPatternCore() = default; + + // The normalized match pattern string that this object represents. + nsString mPattern; + + // The set of atomized URI schemes that this pattern matches. + RefPtr mSchemes; + + // The domain that this matcher matches. If mMatchSubdomain is false, only + // matches the exact domain. If it's true, matches the domain or any + // subdomain. + // + // For instance, "*.foo.com" gives mDomain = "foo.com" and mMatchSubdomain = + // true, and matches "foo.com" or "bar.foo.com" but not "barfoo.com". + // + // While "foo.com" gives mDomain = "foo.com" and mMatchSubdomain = false, + // and matches "foo.com" but not "bar.foo.com". + nsCString mDomain; + bool mMatchSubdomain = false; + + // The glob against which the URL path must match. If null, the path is + // ignored entirely. If non-null, the path must match this glob. + RefPtr mPath; +}; + +class MatchPattern final : public nsISupports, public nsWrapperCache { + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(MatchPattern) + + static already_AddRefed Constructor( + dom::GlobalObject& aGlobal, const nsAString& aPattern, + const MatchPatternOptions& aOptions, ErrorResult& aRv); + + bool Matches(const nsAString& aURL, bool aExplicit, ErrorResult& aRv) const { + return Core()->Matches(aURL, aExplicit, aRv); + } + + bool MatchesAllWebUrls() const { return Core()->MatchesAllWebUrls(); } + + bool Matches(const URLInfo& aURL, bool aExplicit = false) const { + return Core()->Matches(aURL, aExplicit); + } + + bool Matches(const URLInfo& aURL, bool aExplicit, ErrorResult& aRv) const { + return Matches(aURL, aExplicit); + } + + bool MatchesCookie(const CookieInfo& aCookie) const { + return Core()->MatchesCookie(aCookie); + } + + bool MatchesDomain(const nsACString& aDomain) const { + return Core()->MatchesDomain(aDomain); + } + + bool Subsumes(const MatchPattern& aPattern) const { + return Core()->Subsumes(*aPattern.Core()); + } + + bool SubsumesDomain(const MatchPattern& aPattern) const { + return Core()->SubsumesDomain(*aPattern.Core()); + } + + bool Overlaps(const MatchPattern& aPattern) const { + return Core()->Overlaps(*aPattern.Core()); + } + + bool DomainIsWildcard() const { return Core()->DomainIsWildcard(); } + + void GetPattern(nsAString& aPattern) const { Core()->GetPattern(aPattern); } + + MatchPatternCore* Core() const { return mCore; } + + nsISupports* GetParentObject() const { return mParent; } + + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle aGivenProto) override; + + protected: + virtual ~MatchPattern() = default; + + private: + friend class MatchPatternSet; + + explicit MatchPattern(nsISupports* aParent, + already_AddRefed aCore) + : mParent(aParent), mCore(std::move(aCore)) {} + + void Init(JSContext* aCx, const nsAString& aPattern, bool aIgnorePath, + bool aRestrictSchemes, ErrorResult& aRv); + + nsCOMPtr mParent; + + RefPtr mCore; + + public: + // A quick way to check if a particular URL matches without + // actually instantiating a MatchPattern + static bool MatchesAllURLs(const URLInfo& aURL); +}; + +class MatchPatternSetCore final { + public: + NS_INLINE_DECL_THREADSAFE_REFCOUNTING(MatchPatternSetCore) + + using ArrayType = nsTArray>; + + explicit MatchPatternSetCore(ArrayType&& aPatterns) + : mPatterns(std::move(aPatterns)) {} + + static already_AddRefed Constructor( + dom::GlobalObject& aGlobal, + const nsTArray& aPatterns, + const MatchPatternOptions& aOptions, ErrorResult& aRv); + + bool Matches(const nsAString& aURL, bool aExplicit, ErrorResult& aRv) const; + + bool Matches(const URLInfo& aURL, bool aExplicit = false) const; + + bool MatchesAllWebUrls() const; + + bool MatchesCookie(const CookieInfo& aCookie) const; + + bool Subsumes(const MatchPatternCore& aPattern) const; + + bool SubsumesDomain(const MatchPatternCore& aPattern) const; + + bool Overlaps(const MatchPatternCore& aPattern) const; + + bool Overlaps(const MatchPatternSetCore& aPatternSet) const; + + bool OverlapsAll(const MatchPatternSetCore& aPatternSet) const; + + void GetPatterns(ArrayType& aPatterns) { + aPatterns.AppendElements(mPatterns); + } + + private: + friend class MatchPatternSet; + + ~MatchPatternSetCore() = default; + + ArrayType mPatterns; +}; + +class MatchPatternSet final : public nsISupports, public nsWrapperCache { + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(MatchPatternSet) + + using ArrayType = nsTArray>; + + static already_AddRefed Constructor( + dom::GlobalObject& aGlobal, + const nsTArray& aPatterns, + const MatchPatternOptions& aOptions, ErrorResult& aRv); + + bool Matches(const nsAString& aURL, bool aExplicit, ErrorResult& aRv) const { + return Core()->Matches(aURL, aExplicit, aRv); + } + + bool Matches(const URLInfo& aURL, bool aExplicit = false) const { + return Core()->Matches(aURL, aExplicit); + } + + bool Matches(const URLInfo& aURL, bool aExplicit, ErrorResult& aRv) const { + return Matches(aURL, aExplicit); + } + + bool MatchesAllWebUrls() const { return Core()->MatchesAllWebUrls(); } + + bool MatchesCookie(const CookieInfo& aCookie) const { + return Core()->MatchesCookie(aCookie); + } + + bool Subsumes(const MatchPattern& aPattern) const { + return Core()->Subsumes(*aPattern.Core()); + } + + bool SubsumesDomain(const MatchPattern& aPattern) const { + return Core()->SubsumesDomain(*aPattern.Core()); + } + + bool Overlaps(const MatchPattern& aPattern) const { + return Core()->Overlaps(*aPattern.Core()); + } + + bool Overlaps(const MatchPatternSet& aPatternSet) const { + return Core()->Overlaps(*aPatternSet.Core()); + } + + bool OverlapsAll(const MatchPatternSet& aPatternSet) const { + return Core()->OverlapsAll(*aPatternSet.Core()); + } + + void GetPatterns(ArrayType& aPatterns); + + MatchPatternSetCore* Core() const { return mCore; } + + nsISupports* GetParentObject() const { return mParent; } + + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle aGivenProto) override; + + protected: + virtual ~MatchPatternSet() = default; + + private: + explicit MatchPatternSet(nsISupports* aParent, + already_AddRefed aCore) + : mParent(aParent), mCore(std::move(aCore)) {} + + nsCOMPtr mParent; + + RefPtr mCore; + + mozilla::Maybe mPatternsCache; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_MatchPattern_h diff --git a/toolkit/components/extensions/MatchURLFilters.sys.mjs b/toolkit/components/extensions/MatchURLFilters.sys.mjs new file mode 100644 index 0000000000..22060151e7 --- /dev/null +++ b/toolkit/components/extensions/MatchURLFilters.sys.mjs @@ -0,0 +1,174 @@ +/* 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/. */ + +// Match WebNavigation URL Filters. +export class MatchURLFilters { + constructor(filters) { + if (!Array.isArray(filters)) { + throw new TypeError("filters should be an array"); + } + + if (!filters.length) { + throw new Error("filters array should not be empty"); + } + + this.filters = filters; + } + + matches(url) { + let uri = Services.io.newURI(url); + // Set uriURL to an empty object (needed because some schemes, e.g. about doesn't support nsIURL). + let uriURL = {}; + if (uri instanceof Ci.nsIURL) { + uriURL = uri; + } + + // Set host to a empty string by default (needed so that schemes without an host, + // e.g. about, can pass an empty string for host based event filtering as expected). + let host = ""; + try { + host = uri.host; + } catch (e) { + // 'uri.host' throws an exception with some uri schemes (e.g. about). + } + + let port; + try { + port = uri.port; + } catch (e) { + // 'uri.port' throws an exception with some uri schemes (e.g. about), + // in which case it will be |undefined|. + } + + let data = { + // NOTE: This properties are named after the name of their related + // filters (e.g. `pathContains/pathEquals/...` will be tested against the + // `data.path` property, and the same is done for the `host`, `query` and `url` + // components as well). + path: uriURL.filePath, + query: uriURL.query, + host, + port, + url, + }; + + // If any of the filters matches, matches returns true. + return this.filters.some(filter => + this.matchURLFilter({ filter, data, uri, uriURL }) + ); + } + + matchURLFilter({ filter, data, uri, uriURL }) { + // Test for scheme based filtering. + if (filter.schemes) { + // Return false if none of the schemes matches. + if (!filter.schemes.some(scheme => uri.schemeIs(scheme))) { + return false; + } + } + + // Test for exact port matching or included in a range of ports. + if (filter.ports) { + let port = data.port; + if (port === -1) { + // NOTE: currently defaultPort for "resource" and "chrome" schemes defaults to -1, + // for "about", "data" and "javascript" schemes defaults to undefined. + if (["resource", "chrome"].includes(uri.scheme)) { + port = undefined; + } else { + port = Services.io.getDefaultPort(uri.scheme); + } + } + + // Return false if none of the ports (or port ranges) is verified + const portMatch = filter.ports.some(filterPort => { + if (Array.isArray(filterPort)) { + let [lower, upper] = filterPort; + return port >= lower && port <= upper; + } + + return port === filterPort; + }); + + if (!portMatch) { + return false; + } + } + + // Filters on host, url, path, query: + // hostContains, hostEquals, hostSuffix, hostPrefix, + // urlContains, urlEquals, ... + for (let urlComponent of ["host", "path", "query", "url"]) { + if (!this.testMatchOnURLComponent({ urlComponent, data, filter })) { + return false; + } + } + + // urlMatches is a regular expression string and it is tested for matches + // on the "url without the ref". + if (filter.urlMatches) { + let urlWithoutRef = uri.specIgnoringRef; + if (!urlWithoutRef.match(filter.urlMatches)) { + return false; + } + } + + // originAndPathMatches is a regular expression string and it is tested for matches + // on the "url without the query and the ref". + if (filter.originAndPathMatches) { + let urlWithoutQueryAndRef = uri.resolve(uriURL.filePath); + // The above 'uri.resolve(...)' will be null for some URI schemes + // (e.g. about). + // TODO: handle schemes which will not be able to resolve the filePath + // (e.g. for "about:blank", 'urlWithoutQueryAndRef' should be "about:blank" instead + // of null) + if ( + !urlWithoutQueryAndRef || + !urlWithoutQueryAndRef.match(filter.originAndPathMatches) + ) { + return false; + } + } + + return true; + } + + testMatchOnURLComponent({ urlComponent: key, data, filter }) { + // Test for equals. + // NOTE: an empty string should not be considered a filter to skip. + if (filter[`${key}Equals`] != null) { + if (data[key] !== filter[`${key}Equals`]) { + return false; + } + } + + // Test for contains. + if (filter[`${key}Contains`]) { + let value = (key == "host" ? "." : "") + data[key]; + if (!data[key] || !value.includes(filter[`${key}Contains`])) { + return false; + } + } + + // Test for prefix. + if (filter[`${key}Prefix`]) { + if (!data[key] || !data[key].startsWith(filter[`${key}Prefix`])) { + return false; + } + } + + // Test for suffix. + if (filter[`${key}Suffix`]) { + if (!data[key] || !data[key].endsWith(filter[`${key}Suffix`])) { + return false; + } + } + + return true; + } + + serialize() { + return this.filters; + } +} diff --git a/toolkit/components/extensions/MessageChannel.sys.mjs b/toolkit/components/extensions/MessageChannel.sys.mjs new file mode 100644 index 0000000000..65ab2720aa --- /dev/null +++ b/toolkit/components/extensions/MessageChannel.sys.mjs @@ -0,0 +1,1168 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +// @ts-nocheck TODO bug 1580774: Remove this file and its uses. + +/** + * This module provides wrappers around standard message managers to + * simplify bidirectional communication. It currently allows a caller to + * send a message to a single listener, and receive a reply. If there + * are no matching listeners, or the message manager disconnects before + * a reply is received, the caller is returned an error. + * + * The listener end may specify filters for the messages it wishes to + * receive, and the sender end likewise may specify recipient tags to + * match the filters. + * + * The message handler on the listener side may return its response + * value directly, or may return a promise, the resolution or rejection + * of which will be returned instead. The sender end likewise receives a + * promise which resolves or rejects to the listener's response. + * + * + * A basic setup works something like this: + * + * A content script adds a message listener to its global + * ContentFrameMessageManager, with an appropriate set of filters: + * + * { + * init(messageManager, window, extensionID) { + * this.window = window; + * + * MessageChannel.addListener( + * messageManager, "ContentScript:TouchContent", + * this); + * + * this.messageFilterStrict = { + * innerWindowID: getInnerWindowID(window), + * extensionID: extensionID, + * }; + * + * this.messageFilterPermissive = { + * outerWindowID: getOuterWindowID(window), + * }; + * }, + * + * receiveMessage({ target, messageName, sender, recipient, data }) { + * if (messageName == "ContentScript:TouchContent") { + * return new Promise(resolve => { + * this.touchWindow(data.touchWith, result => { + * resolve({ touchResult: result }); + * }); + * }); + * } + * }, + * }; + * + * A script in the parent process sends a message to the content process + * via a tab message manager, including recipient tags to match its + * filter, and an optional sender tag to identify itself: + * + * let data = { touchWith: "pencil" }; + * let sender = { extensionID, contextID }; + * let recipient = { innerWindowID: tab.linkedBrowser.innerWindowID, extensionID }; + * + * MessageChannel.sendMessage( + * tab.linkedBrowser.messageManager, "ContentScript:TouchContent", + * data, {recipient, sender} + * ).then(result => { + * alert(result.touchResult); + * }); + * + * Since the lifetimes of message senders and receivers may not always + * match, either side of the message channel may cancel pending + * responses which match its sender or recipient tags. + * + * For the above client, this might be done from an + * inner-window-destroyed observer, when its target scope is destroyed: + * + * observe(subject, topic, data) { + * if (topic == "inner-window-destroyed") { + * let innerWindowID = subject.QueryInterface(Ci.nsISupportsPRUint64).data; + * + * MessageChannel.abortResponses({ innerWindowID }); + * } + * }, + * + * From the parent, it may be done when its context is being destroyed: + * + * onDestroy() { + * MessageChannel.abortResponses({ + * extensionID: this.extensionID, + * contextID: this.contextID, + * }); + * }, + * + */ + +export let MessageChannel; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + MessageManagerProxy: "resource://gre/modules/MessageManagerProxy.sys.mjs", +}); + +function getMessageManager(target) { + if (typeof target.sendAsyncMessage === "function") { + return target; + } + return new lazy.MessageManagerProxy(target); +} + +function matches(target, messageManager) { + return target === messageManager || target.messageManager === messageManager; +} + +const { DEBUG } = AppConstants; + +// Idle callback timeout for low-priority message dispatch. +const LOW_PRIORITY_TIMEOUT_MS = 250; + +const MESSAGE_MESSAGES = "MessageChannel:Messages"; +const MESSAGE_RESPONSE = "MessageChannel:Response"; + +var _deferredResult; +var _makeDeferred = (resolve, reject) => { + // We use arrow functions here and refer to the outer variables via + // `this`, to avoid a lexical name lookup. Yes, it makes a difference. + // No, I don't like it any more than you do. + _deferredResult.resolve = resolve; + _deferredResult.reject = reject; +}; + +/** + * Helper to create a new Promise without allocating any closures to + * receive its resolution functions. + * + * I know what you're thinking: "This is crazy. There is no possible way + * this can be necessary. Just use the ordinary Promise constructor the + * way it was meant to be used, you lunatic." + * + * And, against all odds, it turns out that you're wrong. Creating + * lambdas to receive promise resolution functions consistently turns + * out to be one of the most expensive parts of message dispatch in this + * code. + * + * So we do the stupid micro-optimization, and try to live with + * ourselves for it. + * + * (See also bug 1404950.) + * + * @returns {object} + */ +let Deferred = () => { + let res = {}; + _deferredResult = res; + res.promise = new Promise(_makeDeferred); + _deferredResult = null; + return res; +}; + +/** + * Handles the mapping and dispatching of messages to their registered + * handlers. There is one broker per message manager and class of + * messages. Each class of messages is mapped to one native message + * name, e.g., "MessageChannel:Message", and is dispatched to handlers + * based on an internal message name, e.g., "Extension:ExecuteScript". + */ +class FilteringMessageManager { + /** + * @param {string} messageName + * The name of the native message this broker listens for. + * @param {Function} callback + * A function which is called for each message after it has been + * mapped to its handler. The function receives two arguments: + * + * result: + * An object containing either a `handler` or an `error` property. + * If no error occurs, `handler` will be a matching handler that + * was registered by `addHandler`. Otherwise, the `error` property + * will contain an object describing the error. + * + * data: + * An object describing the message, as defined in + * `MessageChannel.addListener`. + * @param {nsIMessageListenerManager} messageManager + */ + constructor(messageName, callback, messageManager) { + this.messageName = messageName; + this.callback = callback; + this.messageManager = messageManager; + + this.messageManager.addMessageListener(this.messageName, this, true); + + this.handlers = new Map(); + } + + /** + * Receives a set of messages from our message manager, maps each to a + * handler, and passes the results to our message callbacks. + */ + receiveMessage({ data, target }) { + data.forEach(msg => { + if (msg) { + let handlers = Array.from( + this.getHandlers(msg.messageName, msg.sender || null, msg.recipient) + ); + + msg.target = target; + this.callback(handlers, msg); + } + }); + } + + /** + * Iterates over all handlers for the given message name. If `recipient` + * is provided, only iterates over handlers whose filters match it. + * + * @param {string|number} messageName + * The message for which to return handlers. + * @param {object} sender + * The sender data on which to filter handlers. + * @param {object} recipient + * The recipient data on which to filter handlers. + */ + *getHandlers(messageName, sender, recipient) { + let handlers = this.handlers.get(messageName) || new Set(); + for (let handler of handlers) { + if ( + MessageChannel.matchesFilter( + handler.messageFilterStrict || null, + recipient + ) && + MessageChannel.matchesFilter( + handler.messageFilterPermissive || null, + recipient, + false + ) && + (!handler.filterMessage || handler.filterMessage(sender, recipient)) + ) { + yield handler; + } + } + } + + /** + * Registers a handler for the given message. + * + * @param {string} messageName + * The internal message name for which to register the handler. + * @param {object} handler + * An opaque handler object. The object may have a + * `messageFilterStrict` and/or a `messageFilterPermissive` + * property and/or a `filterMessage` method on which to filter messages. + * + * Final dispatching is handled by the message callback passed to + * the constructor. + */ + addHandler(messageName, handler) { + if (!this.handlers.has(messageName)) { + this.handlers.set(messageName, new Set()); + } + + this.handlers.get(messageName).add(handler); + } + + /** + * Unregisters a handler for the given message. + * + * @param {string} messageName + * The internal message name for which to unregister the handler. + * @param {object} handler + * The handler object to unregister. + */ + removeHandler(messageName, handler) { + if (this.handlers.has(messageName)) { + this.handlers.get(messageName).delete(handler); + } + } +} + +/** + * A message dispatch and response manager that wrapse a single native + * message manager. Handles dispatching messages through the manager + * (optionally coalescing several low-priority messages and dispatching + * them during an idle slice), and mapping their responses to the + * appropriate response callbacks. + * + * Note that this is a simplified subclass of FilteringMessageManager + * that only supports one handler per message, and does not support + * filtering. + */ +class ResponseManager extends FilteringMessageManager { + constructor(messageName, callback, messageManager) { + super(messageName, callback, messageManager); + + this.idleMessages = []; + this.idleScheduled = false; + this.onIdle = this.onIdle.bind(this); + } + + /** + * Schedules a new idle callback to dispatch pending low-priority + * messages, if one is not already scheduled. + */ + scheduleIdleCallback() { + if (!this.idleScheduled) { + ChromeUtils.idleDispatch(this.onIdle, { + timeout: LOW_PRIORITY_TIMEOUT_MS, + }); + this.idleScheduled = true; + } + } + + /** + * Called when the event queue is idle, and dispatches any pending + * low-priority messages in a single chunk. + * + * @param {IdleDeadline} deadline + */ + onIdle(deadline) { + this.idleScheduled = false; + + let messages = this.idleMessages; + this.idleMessages = []; + + let msgs = messages.map(msg => msg.getMessage()); + try { + this.messageManager.sendAsyncMessage(MESSAGE_MESSAGES, msgs); + } catch (e) { + for (let msg of messages) { + msg.reject(e); + } + } + } + + /** + * Sends a message through our wrapped message manager, or schedules + * it for low-priority dispatch during an idle callback. + * + * @param {any} message + * The message to send. + * @param {object} [options] + * Message dispatch options. + * @param {boolean} [options.lowPriority = false] + * If true, dispatches the message in a single chunk with other + * low-priority messages the next time the event queue is idle. + */ + sendMessage(message, options = {}) { + if (options.lowPriority) { + this.idleMessages.push(message); + this.scheduleIdleCallback(); + } else { + this.messageManager.sendAsyncMessage(MESSAGE_MESSAGES, [ + message.getMessage(), + ]); + } + } + + receiveMessage({ data, target }) { + data.target = target; + + this.callback(this.handlers.get(data.messageName), data); + } + + *getHandlers(messageName, sender, recipient) { + let handler = this.handlers.get(messageName); + if (handler) { + yield handler; + } + } + + addHandler(messageName, handler) { + if (DEBUG && this.handlers.has(messageName)) { + throw new Error( + `Handler already registered for response ID ${messageName}` + ); + } + this.handlers.set(messageName, handler); + } + + /** + * Unregisters a handler for the given message. + * + * @param {string} messageName + * The internal message name for which to unregister the handler. + * @param {object} handler + * The handler object to unregister. + */ + removeHandler(messageName, handler) { + if (DEBUG && this.handlers.get(messageName) !== handler) { + Cu.reportError( + `Attempting to remove unexpected response handler for ${messageName}` + ); + } + this.handlers.delete(messageName); + } +} + +/** + * Manages mappings of message managers to their corresponding message + * brokers. Brokers are lazily created for each message manager the + * first time they are accessed. In the case of content frame message + * managers, they are also automatically destroyed when the frame + * unload event fires. + */ +class FilteringMessageManagerMap extends Map { + // Unfortunately, we can't use a WeakMap for this, because message + // managers do not support preserved wrappers. + + /** + * @param {string} messageName + * The native message name passed to `FilteringMessageManager` constructors. + * @param {Function} callback + * The message callback function passed to + * `FilteringMessageManager` constructors. + * @param {Function} [constructor = FilteringMessageManager] + * The constructor for the message manager class that we're + * mapping to. + */ + constructor(messageName, callback, constructor = FilteringMessageManager) { + super(); + + this.messageName = messageName; + this.callback = callback; + this._constructor = constructor; + } + + /** + * Returns, and possibly creates, a message broker for the given + * message manager. + * + * @param {nsIMessageListenerManager} target + * The message manager for which to return a broker. + * + * @returns {FilteringMessageManager} + */ + get(target) { + let broker = super.get(target); + if (broker) { + return broker; + } + + broker = new this._constructor(this.messageName, this.callback, target); + this.set(target, broker); + + // XXXbz if target is really known to be a MessageListenerManager, + // do we need this isInstance check? + if (EventTarget.isInstance(target)) { + let onUnload = event => { + target.removeEventListener("unload", onUnload); + this.delete(target); + }; + target.addEventListener("unload", onUnload); + } + + return broker; + } +} + +/** + * Represents a message being sent through a MessageChannel, which may + * or may not have been dispatched yet, and is pending a response. + * + * When a response has been received, or the message has been canceled, + * this class is responsible for settling the response promise as + * appropriate. + * + * @param {number} channelId + * The unique ID for this message. + * @param {any} message + * The message contents. + * @param {object} sender + * An object describing the sender of the message, used by + * `abortResponses` to determine whether the message should be + * aborted. + * @param {ResponseManager} broker + * The response broker on which we're expected to receive a + * reply. + */ +class PendingMessage { + constructor(channelId, message, sender, broker) { + this.channelId = channelId; + this.message = message; + this.sender = sender; + this.broker = broker; + this.deferred = Deferred(); + + MessageChannel.pendingResponses.add(this); + } + + /** + * Cleans up after this message once we've received or aborted a + * response. + */ + cleanup() { + if (this.broker) { + this.broker.removeHandler(this.channelId, this); + MessageChannel.pendingResponses.delete(this); + + this.message = null; + this.broker = null; + } + } + + /** + * Returns the promise which will resolve when we've received or + * aborted a response to this message. + */ + get promise() { + return this.deferred.promise; + } + + /** + * Resolves the message's response promise, and cleans up. + * + * @param {any} value + */ + resolve(value) { + this.cleanup(); + this.deferred.resolve(value); + } + + /** + * Rejects the message's response promise, and cleans up. + * + * @param {any} value + */ + reject(value) { + this.cleanup(); + this.deferred.reject(value); + } + + get messageManager() { + return this.broker.messageManager; + } + + /** + * Returns the contents of the message to be sent over a message + * manager, and registers the response with our response broker. + * + * Returns null if the response has already been canceled, and the + * message should not be sent. + * + * @returns {any} + */ + getMessage() { + let msg = null; + if (this.broker) { + this.broker.addHandler(this.channelId, this); + msg = this.message; + this.message = null; + } + return msg; + } +} + +// Web workers has MessageChannel API, which is unrelated to this. +// eslint-disable-next-line no-global-assign +MessageChannel = { + init() { + Services.obs.addObserver(this, "message-manager-close"); + Services.obs.addObserver(this, "message-manager-disconnect"); + + this.messageManagers = new FilteringMessageManagerMap( + MESSAGE_MESSAGES, + this._handleMessage.bind(this) + ); + + this.responseManagers = new FilteringMessageManagerMap( + MESSAGE_RESPONSE, + this._handleResponse.bind(this), + ResponseManager + ); + + /** + * @property {Set} pendingResponses + * Contains a set of pending responses, either waiting to be + * received or waiting to be sent. + * + * The response object must be a deferred promise with the following + * properties: + * + * promise: + * The promise object which resolves or rejects when the response + * is no longer pending. + * + * reject: + * A function which, when called, causes the `promise` object to be + * rejected. + * + * sender: + * A sender object, as passed to `sendMessage. + * + * messageManager: + * The message manager the response will be sent or received on. + * + * When the promise resolves or rejects, it must be removed from the + * list. + * + * These values are used to clear pending responses when execution + * contexts are destroyed. + */ + this.pendingResponses = new Set(); + + /** + * @property {LimitedSet} abortedResponses + * Contains the message name of a limited number of aborted response + * handlers, the responses for which will be ignored. + */ + this.abortedResponses = new ExtensionUtils.LimitedSet(30); + }, + + RESULT_SUCCESS: 0, + RESULT_DISCONNECTED: 1, + RESULT_NO_HANDLER: 2, + RESULT_MULTIPLE_HANDLERS: 3, + RESULT_ERROR: 4, + RESULT_NO_RESPONSE: 5, + + REASON_DISCONNECTED: { + result: 1, // this.RESULT_DISCONNECTED + message: "Message manager disconnected", + }, + + /** + * Specifies that only a single listener matching the specified + * recipient tag may be listening for the given message, at the other + * end of the target message manager. + * + * If no matching listeners exist, a RESULT_NO_HANDLER error will be + * returned. If multiple matching listeners exist, a + * RESULT_MULTIPLE_HANDLERS error will be returned. + */ + RESPONSE_SINGLE: 0, + + /** + * If multiple message managers matching the specified recipient tag + * are listening for a message, all listeners are notified, but only + * the first response or error is returned. + * + * Only handlers which return a value other than `undefined` are + * considered to have responded. Returning a Promise which evaluates + * to `undefined` is interpreted as an explicit response. + * + * If no matching listeners exist, a RESULT_NO_HANDLER error will be + * returned. If no listeners return a response, a RESULT_NO_RESPONSE + * error will be returned. + */ + RESPONSE_FIRST: 1, + + /** + * If multiple message managers matching the specified recipient tag + * are listening for a message, all listeners are notified, and all + * responses are returned as an array, once all listeners have + * replied. + */ + RESPONSE_ALL: 2, + + /** + * Fire-and-forget: The sender of this message does not expect a reply. + */ + RESPONSE_NONE: 3, + + /** + * Initializes message handlers for the given message managers if needed. + * + * @param {Array} messageManagers + */ + setupMessageManagers(messageManagers) { + for (let mm of messageManagers) { + // This call initializes a FilteringMessageManager for |mm| if needed. + // The FilteringMessageManager must be created to make sure that senders + // of messages that expect a reply, such as MessageChannel:Message, do + // actually receive a default reply even if there are no explicit message + // handlers. + this.messageManagers.get(mm); + } + }, + + /** + * Returns true if the properties of the `data` object match those in + * the `filter` object. Matching is done on a strict equality basis, + * and the behavior varies depending on the value of the `strict` + * parameter. + * + * @param {object?} filter + * The filter object to match against. + * @param {object} data + * The data object being matched. + * @param {boolean} [strict=true] + * If true, all properties in the `filter` object have a + * corresponding property in `data` with the same value. If + * false, properties present in both objects must have the same + * value. + * @returns {boolean} True if the objects match. + */ + matchesFilter(filter, data, strict = true) { + if (!filter) { + return true; + } + if (strict) { + return Object.keys(filter).every(key => { + return key in data && data[key] === filter[key]; + }); + } + return Object.keys(filter).every(key => { + return !(key in data) || data[key] === filter[key]; + }); + }, + + /** + * Adds a message listener to the given message manager. + * + * @param {nsIMessageListenerManager|Array} targets + * The message managers on which to listen. + * @param {string|number} messageName + * The name of the message to listen for. + * @param {MessageReceiver} handler + * The handler to dispatch to. Must be an object with the following + * properties: + * + * receiveMessage: + * A method which is called for each message received by the + * listener. The method takes one argument, an object, with the + * following properties: + * + * messageName: + * The internal message name, as passed to `sendMessage`. + * + * target: + * The message manager which received this message. + * + * channelId: + * The internal ID of the transaction, used to map responses to + * the original sender. + * + * sender: + * An object describing the sender, as passed to `sendMessage`. + * + * recipient: + * An object describing the recipient, as passed to + * `sendMessage`. + * + * data: + * The contents of the message, as passed to `sendMessage`. + * + * The method may return any structured-clone-compatible + * object, which will be returned as a response to the message + * sender. It may also instead return a `Promise`, the + * resolution or rejection value of which will likewise be + * returned to the message sender. + * + * messageFilterStrict: + * An object containing arbitrary properties on which to filter + * received messages. Messages will only be dispatched to this + * object if the `recipient` object passed to `sendMessage` + * matches this filter, as determined by `matchesFilter` with + * `strict=true`. + * + * messageFilterPermissive: + * An object containing arbitrary properties on which to filter + * received messages. Messages will only be dispatched to this + * object if the `recipient` object passed to `sendMessage` + * matches this filter, as determined by `matchesFilter` with + * `strict=false`. + * + * filterMessage: + * An optional function that prevents the handler from handling a + * message by returning `false`. See `getHandlers` for the parameters. + */ + addListener(targets, messageName, handler) { + if (!Array.isArray(targets)) { + targets = [targets]; + } + for (let target of targets) { + this.messageManagers.get(target).addHandler(messageName, handler); + } + }, + + /** + * Removes a message listener from the given message manager. + * + * @param {nsIMessageListenerManager|Array} targets + * The message managers on which to stop listening. + * @param {string|number} messageName + * The name of the message to stop listening for. + * @param {MessageReceiver} handler + * The handler to stop dispatching to. + */ + removeListener(targets, messageName, handler) { + if (!Array.isArray(targets)) { + targets = [targets]; + } + for (let target of targets) { + if (this.messageManagers.has(target)) { + this.messageManagers.get(target).removeHandler(messageName, handler); + } + } + }, + + /** + * Sends a message via the given message manager. Returns a promise which + * resolves or rejects with the return value of the message receiver. + * + * The promise also rejects if there is no matching listener, or the other + * side of the message manager disconnects before the response is received. + * + * @param {nsIMessageSender} target + * The message manager on which to send the message. + * @param {string} messageName + * The name of the message to send, as passed to `addListener`. + * @param {object} data + * A structured-clone-compatible object to send to the message + * recipient. + * @param {object} [options] + * An object containing any of the following properties: + * @param {object} [options.recipient] + * A structured-clone-compatible object to identify the message + * recipient. The object must match the `messageFilterStrict` and + * `messageFilterPermissive` filters defined by recipients in order + * for the message to be received. + * @param {object} [options.sender] + * A structured-clone-compatible object to identify the message + * sender. This object may also be used to avoid delivering the + * message to the sender, and as a filter to prematurely + * abort responses when the sender is being destroyed. + * @see `abortResponses`. + * @param {boolean} [options.lowPriority = false] + * If true, treat this as a low-priority message, and attempt to + * send it in the same chunk as other messages to the same target + * the next time the event queue is idle. This option reduces + * messaging overhead at the expense of adding some latency. + * @param {integer} [options.responseType = RESPONSE_SINGLE] + * Specifies the type of response expected. See the `RESPONSE_*` + * contents for details. + * @returns {Promise} + */ + sendMessage(target, messageName, data, options = {}) { + let sender = options.sender || {}; + let recipient = options.recipient || {}; + let responseType = options.responseType || this.RESPONSE_SINGLE; + + let channelId = ExtensionUtils.getUniqueId(); + let message = { + messageName, + channelId, + sender, + recipient, + data, + responseType, + }; + data = null; + + if (responseType == this.RESPONSE_NONE) { + try { + target.sendAsyncMessage(MESSAGE_MESSAGES, [message]); + } catch (e) { + // Caller is not expecting a reply, so dump the error to the console. + Cu.reportError(e); + return Promise.reject(e); + } + return Promise.resolve(); // Not expecting any reply. + } + + let broker = this.responseManagers.get(target); + let pending = new PendingMessage(channelId, message, recipient, broker); + message = null; + try { + broker.sendMessage(pending, options); + } catch (e) { + pending.reject(e); + } + return pending.promise; + }, + + _callHandlers(handlers, data) { + let responseType = data.responseType; + + // At least one handler is required for all response types but + // RESPONSE_ALL. + if (!handlers.length && responseType != this.RESPONSE_ALL) { + return Promise.reject({ + result: MessageChannel.RESULT_NO_HANDLER, + message: "No matching message handler", + }); + } + + if (responseType == this.RESPONSE_SINGLE) { + if (handlers.length > 1) { + return Promise.reject({ + result: MessageChannel.RESULT_MULTIPLE_HANDLERS, + message: `Multiple matching handlers for ${data.messageName}`, + }); + } + + // Note: We use `new Promise` rather than `Promise.resolve` here + // so that errors from the handler are trapped and converted into + // rejected promises. + return new Promise(resolve => { + resolve(handlers[0].receiveMessage(data)); + }); + } + + let responses = handlers.map((handler, i) => { + try { + return handler.receiveMessage(data, i + 1 == handlers.length); + } catch (e) { + return Promise.reject(e); + } + }); + data = null; + responses = responses.filter(response => response !== undefined); + + switch (responseType) { + case this.RESPONSE_FIRST: + if (!responses.length) { + return Promise.reject({ + result: MessageChannel.RESULT_NO_RESPONSE, + message: "No handler returned a response", + }); + } + + return Promise.race(responses); + + case this.RESPONSE_ALL: + return Promise.all(responses); + } + return Promise.reject({ message: "Invalid response type" }); + }, + + /** + * Handles dispatching message callbacks from the message brokers to their + * appropriate `MessageReceivers`, and routing the responses back to the + * original senders. + * + * Each handler object is a `MessageReceiver` object as passed to + * `addListener`. + * + * @param {Array} handlers + * @param {object} data + * @param {nsIMessageSender|{messageManager:nsIMessageSender}} data.target + */ + _handleMessage(handlers, data) { + if (data.responseType == this.RESPONSE_NONE) { + handlers.forEach(handler => { + // The sender expects no reply, so dump any errors to the console. + new Promise(resolve => { + resolve(handler.receiveMessage(data)); + }).catch(e => { + Cu.reportError(e.stack ? `${e}\n${e.stack}` : e.message || e); + }); + }); + data = null; + // Note: Unhandled messages are silently dropped. + return; + } + + let target = getMessageManager(data.target); + + let deferred = { + sender: data.sender, + messageManager: target, + channelId: data.channelId, + respondingSide: true, + }; + + let cleanup = () => { + this.pendingResponses.delete(deferred); + if (target.dispose) { + target.dispose(); + } + }; + this.pendingResponses.add(deferred); + + deferred.promise = new Promise((resolve, reject) => { + deferred.reject = reject; + + this._callHandlers(handlers, data).then(resolve, reject); + data = null; + }) + .then( + value => { + let response = { + result: this.RESULT_SUCCESS, + messageName: deferred.channelId, + recipient: {}, + value, + }; + + if (target.isDisconnected) { + // Target is disconnected. We can't send an error response, so + // don't even try. + return; + } + target.sendAsyncMessage(MESSAGE_RESPONSE, response); + }, + error => { + if (target.isDisconnected) { + // Target is disconnected. We can't send an error response, so + // don't even try. + if ( + error.result !== this.RESULT_DISCONNECTED && + error.result !== this.RESULT_NO_RESPONSE + ) { + Cu.reportError( + Cu.getClassName(error, false) === "Object" + ? error.message + : error + ); + } + return; + } + + let response = { + result: this.RESULT_ERROR, + messageName: deferred.channelId, + recipient: {}, + error: {}, + }; + + if (error && typeof error == "object") { + if (error.result) { + response.result = error.result; + } + // Error objects are not structured-clonable, so just copy + // over the important properties. + for (let key of [ + "fileName", + "filename", + "lineNumber", + "columnNumber", + "message", + "stack", + "result", + "mozWebExtLocation", + ]) { + if (key in error) { + response.error[key] = error[key]; + } + } + } + + target.sendAsyncMessage(MESSAGE_RESPONSE, response); + } + ) + .then(cleanup, e => { + cleanup(); + Cu.reportError(e); + }); + }, + + /** + * Handles message callbacks from the response brokers. + * + * @param {MessageHandler?} handler + * A deferred object created by `sendMessage`, to be resolved + * or rejected based on the contents of the response. + * @param {object} data + * @param {nsIMessageSender|{messageManager:nsIMessageSender}} data.target + */ + _handleResponse(handler, data) { + // If we have an error at this point, we have handler to report it to, + // so just log it. + if (!handler) { + if (this.abortedResponses.has(data.messageName)) { + this.abortedResponses.delete(data.messageName); + Services.console.logStringMessage( + `Ignoring response to aborted listener for ${data.messageName}` + ); + } else { + Cu.reportError( + `No matching message response handler for ${data.messageName}` + ); + } + } else if (data.result === this.RESULT_SUCCESS) { + handler.resolve(data.value); + } else { + handler.reject(data.error); + } + }, + + /** + * Aborts pending message response for the specific channel. + * + * @param {string} channelId + * A string for channelId of the response. + * @param {object} reason + * An object describing the reason the response was aborted. + * Will be passed to the promise rejection handler of the aborted + * response. + */ + abortChannel(channelId, reason) { + for (let response of this.pendingResponses) { + if (channelId === response.channelId && response.respondingSide) { + this.pendingResponses.delete(response); + response.reject(reason); + } + } + }, + + /** + * Aborts any pending message responses to senders matching the given + * filter. + * + * @param {object} sender + * The object on which to filter senders, as determined by + * `matchesFilter`. + * @param {object} [reason] + * An optional object describing the reason the response was aborted. + * Will be passed to the promise rejection handler of all aborted + * responses. + */ + abortResponses(sender, reason = this.REASON_DISCONNECTED) { + for (let response of this.pendingResponses) { + if (this.matchesFilter(sender, response.sender)) { + this.pendingResponses.delete(response); + this.abortedResponses.add(response.channelId); + response.reject(reason); + } + } + }, + + /** + * Aborts any pending message responses to the broker for the given + * message manager. + * + * @param {nsIMessageListenerManager} target + * The message manager for which to abort brokers. + * @param {object} reason + * An object describing the reason the responses were aborted. + * Will be passed to the promise rejection handler of all aborted + * responses. + */ + abortMessageManager(target, reason) { + for (let response of this.pendingResponses) { + if (matches(response.messageManager, target)) { + this.abortedResponses.add(response.channelId); + response.reject(reason); + } + } + }, + + observe(subject, topic, data) { + switch (topic) { + case "message-manager-close": + case "message-manager-disconnect": + try { + if (this.responseManagers.has(subject)) { + this.abortMessageManager(subject, this.REASON_DISCONNECTED); + } + } finally { + this.responseManagers.delete(subject); + this.messageManagers.delete(subject); + } + break; + } + }, +}; + +MessageChannel.init(); diff --git a/toolkit/components/extensions/MessageManagerProxy.sys.mjs b/toolkit/components/extensions/MessageManagerProxy.sys.mjs new file mode 100644 index 0000000000..387b5876e1 --- /dev/null +++ b/toolkit/components/extensions/MessageManagerProxy.sys.mjs @@ -0,0 +1,212 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +// @ts-nocheck TODO: Many references to old types which don't exist anymore. + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { DefaultMap } = ExtensionUtils; + +/** + * Acts as a proxy for a message manager or message manager owner, and + * tracks docShell swaps so that messages are always sent to the same + * receiver, even if it is moved to a different . + * + * @param {nsIMessageSender|Element} target + * The target message manager on which to send messages, or the + * element which owns it. + */ +export class MessageManagerProxy { + constructor(target) { + this.listeners = new DefaultMap(() => new Map()); + this.closed = false; + + if (target instanceof Ci.nsIMessageSender) { + this.messageManager = target; + } else { + this.addListeners(target); + } + + Services.obs.addObserver(this, "message-manager-close"); + } + + /** + * Disposes of the proxy object, removes event listeners, and drops + * all references to the underlying message manager. + * + * Must be called before the last reference to the proxy is dropped, + * unless the underlying message manager or is also being + * destroyed. + */ + dispose() { + if (this.eventTarget) { + this.removeListeners(this.eventTarget); + this.eventTarget = null; + } + this.messageManager = null; + + Services.obs.removeObserver(this, "message-manager-close"); + } + + observe(subject, topic, data) { + if (topic === "message-manager-close") { + if (subject === this.messageManager) { + this.closed = true; + } + } + } + + /** + * Returns true if the given target is the same as, or owns, the given + * message manager. + * + * @param {nsIMessageSender|MessageManagerProxy|Element} target + * The message manager, MessageManagerProxy, or + * element against which to match. + * @param {nsIMessageSender} messageManager + * The message manager against which to match `target`. + * + * @returns {boolean} + * True if `messageManager` is the same object as `target`, or + * `target` is a MessageManagerProxy or element that + * is tied to it. + */ + static matches(target, messageManager) { + return ( + target === messageManager || target.messageManager === messageManager + ); + } + + /** + * @property {nsIMessageSender|null} messageManager + * The message manager that is currently being proxied. This + * may change during the life of the proxy object, so should + * not be stored elsewhere. + */ + + /** + * Sends a message on the proxied message manager. + * + * @param {Array} args + * Arguments to be passed verbatim to the underlying + * sendAsyncMessage method. + * @returns {undefined} + */ + sendAsyncMessage(...args) { + if (this.messageManager) { + return this.messageManager.sendAsyncMessage(...args); + } + + Cu.reportError( + `Cannot send message: Other side disconnected: ${uneval(args)}` + ); + } + + get isDisconnected() { + return this.closed || !this.messageManager; + } + + /** + * Adds a message listener to the current message manager, and + * transfers it to the new message manager after a docShell swap. + * + * @param {string} message + * The name of the message to listen for. + * @param {nsIMessageListener} listener + * The listener to add. + * @param {boolean} [listenWhenClosed = false] + * If true, the listener will receive messages which were sent + * after the remote side of the listener began closing. + */ + addMessageListener(message, listener, listenWhenClosed = false) { + this.messageManager.addMessageListener(message, listener, listenWhenClosed); + this.listeners.get(message).set(listener, listenWhenClosed); + } + + /** + * Adds a message listener from the current message manager. + * + * @param {string} message + * The name of the message to stop listening for. + * @param {nsIMessageListener} listener + * The listener to remove. + */ + removeMessageListener(message, listener) { + this.messageManager.removeMessageListener(message, listener); + + let listeners = this.listeners.get(message); + listeners.delete(listener); + if (!listeners.size) { + this.listeners.delete(message); + } + } + + /** + * Iterates over all of the currently registered message listeners. + * + * @private + */ + *iterListeners() { + for (let [message, listeners] of this.listeners) { + for (let [listener, listenWhenClosed] of listeners) { + yield { message, listener, listenWhenClosed }; + } + } + } + + /** + * Adds docShell swap listeners to the message manager owner. + * + * @param {Browser} target + * The target element. + * @private + */ + addListeners(target) { + target.addEventListener("SwapDocShells", this); + + this.eventTarget = target; + this.messageManager = target.messageManager; + + for (let { message, listener, listenWhenClosed } of this.iterListeners()) { + this.messageManager.addMessageListener( + message, + listener, + listenWhenClosed + ); + } + } + + /** + * Removes docShell swap listeners to the message manager owner. + * + * @param {Element} target + * The target element. + * @private + */ + removeListeners(target) { + target.removeEventListener("SwapDocShells", this); + + for (let { message, listener } of this.iterListeners()) { + this.messageManager.removeMessageListener(message, listener); + } + } + + handleEvent(event) { + if (event.type == "SwapDocShells") { + this.removeListeners(this.eventTarget); + // The SwapDocShells event is dispatched for both browsers that are being + // swapped. To avoid double-swapping, register the event handler after + // both SwapDocShells events have fired. + this.eventTarget.addEventListener( + "EndSwapDocShells", + () => { + this.addListeners(event.detail); + }, + { once: true } + ); + } + } +} diff --git a/toolkit/components/extensions/NativeManifests.sys.mjs b/toolkit/components/extensions/NativeManifests.sys.mjs new file mode 100644 index 0000000000..e56df4abc2 --- /dev/null +++ b/toolkit/components/extensions/NativeManifests.sys.mjs @@ -0,0 +1,173 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Schemas: "resource://gre/modules/Schemas.sys.mjs", + WindowsRegistry: "resource://gre/modules/WindowsRegistry.sys.mjs", +}); + +const DASHED = AppConstants.platform === "linux"; + +// Supported native manifest types, with platform-specific slugs. +const TYPES = { + stdio: DASHED ? "native-messaging-hosts" : "NativeMessagingHosts", + storage: DASHED ? "managed-storage" : "ManagedStorage", + pkcs11: DASHED ? "pkcs11-modules" : "PKCS11Modules", +}; + +const NATIVE_MANIFEST_SCHEMA = + "chrome://extensions/content/schemas/native_manifest.json"; + +const REGPATH = "Software\\Mozilla"; + +export var NativeManifests = { + _initializePromise: null, + _lookup: null, + + init() { + if (!this._initializePromise) { + let platform = AppConstants.platform; + if (platform == "win") { + this._lookup = this._winLookup; + } else if (platform == "macosx" || platform == "linux") { + let dirs = [ + Services.dirsvc.get("XREUserNativeManifests", Ci.nsIFile).path, + Services.dirsvc.get("XRESysNativeManifests", Ci.nsIFile).path, + ]; + this._lookup = (type, name, context) => + this._tryPaths(type, name, dirs, context); + } else { + throw new Error( + `Native manifests are not supported on ${AppConstants.platform}` + ); + } + this._initializePromise = lazy.Schemas.load(NATIVE_MANIFEST_SCHEMA); + } + return this._initializePromise; + }, + + async _winLookup(type, name, context) { + const REGISTRY = Ci.nsIWindowsRegKey; + let regPath = `${REGPATH}\\${TYPES[type]}\\${name}`; + let path = lazy.WindowsRegistry.readRegKey( + REGISTRY.ROOT_KEY_CURRENT_USER, + regPath, + "", + REGISTRY.WOW64_64 + ); + if (!path) { + path = lazy.WindowsRegistry.readRegKey( + REGISTRY.ROOT_KEY_LOCAL_MACHINE, + regPath, + "", + REGISTRY.WOW64_32 + ); + } + if (!path) { + path = lazy.WindowsRegistry.readRegKey( + REGISTRY.ROOT_KEY_LOCAL_MACHINE, + regPath, + "", + REGISTRY.WOW64_64 + ); + } + if (!path) { + return null; + } + + // Normalize in case the extension used / instead of \. + path = path.replaceAll("/", "\\"); + + let manifest = await this._tryPath(type, path, name, context, true); + return manifest ? { path, manifest } : null; + }, + + async _tryPath(type, path, name, context, logIfNotFound) { + let manifest; + try { + manifest = await IOUtils.readJSON(path); + } catch (ex) { + if (ex instanceof SyntaxError && ex.message.startsWith("JSON.parse:")) { + Cu.reportError(`Error parsing native manifest ${path}: ${ex.message}`); + return null; + } + if (DOMException.isInstance(ex) && ex.name == "NotFoundError") { + if (logIfNotFound) { + Cu.reportError( + `Error reading native manifest file ${path}: file is referenced in the registry but does not exist` + ); + } + return null; + } + Cu.reportError(ex); + return null; + } + let normalized = lazy.Schemas.normalize( + manifest, + "manifest.NativeManifest", + context + ); + if (normalized.error) { + Cu.reportError(normalized.error); + return null; + } + manifest = normalized.value; + + if (manifest.type !== type) { + Cu.reportError( + `Native manifest ${path} has type property ${manifest.type} (expected ${type})` + ); + return null; + } + if (manifest.name !== name) { + Cu.reportError( + `Native manifest ${path} has name property ${manifest.name} (expected ${name})` + ); + return null; + } + if ( + manifest.allowed_extensions && + !manifest.allowed_extensions.includes(context.extension.id) + ) { + Cu.reportError( + `This extension does not have permission to use native manifest ${path}` + ); + return null; + } + + return manifest; + }, + + async _tryPaths(type, name, dirs, context) { + for (let dir of dirs) { + let path = PathUtils.join(dir, TYPES[type], `${name}.json`); + let manifest = await this._tryPath(type, path, name, context, false); + if (manifest) { + return { path, manifest }; + } + } + return null; + }, + + /** + * Search for a valid native manifest of the given type and name. + * The directories searched and rules for manifest validation are all + * detailed in the Native Manifests documentation. + * + * @param {string} type The type, one of: "pkcs11", "stdio" or "storage". + * @param {string} name The name of the manifest to search for. + * @param {object} context A context object as expected by Schemas.normalize. + * @returns {object} The contents of the validated manifest, or null if + * no valid manifest can be found for this type and name. + */ + lookupManifest(type, name, context) { + return this.init().then(() => this._lookup(type, name, context)); + }, +}; diff --git a/toolkit/components/extensions/NativeMessaging.sys.mjs b/toolkit/components/extensions/NativeMessaging.sys.mjs new file mode 100644 index 0000000000..dcd8fe7807 --- /dev/null +++ b/toolkit/components/extensions/NativeMessaging.sys.mjs @@ -0,0 +1,391 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + NativeManifests: "resource://gre/modules/NativeManifests.sys.mjs", + Subprocess: "resource://gre/modules/Subprocess.sys.mjs", +}); + +const { ExtensionError, promiseTimeout } = ExtensionUtils; + +// For a graceful shutdown (i.e., when the extension is unloaded or when it +// explicitly calls disconnect() on a native port), how long we give the native +// application to exit before we start trying to kill it. (in milliseconds) +const GRACEFUL_SHUTDOWN_TIME = 3000; + +// Hard limits on maximum message size that can be read/written +// These are defined in the native messaging documentation, note that +// the write limit is imposed by the "wire protocol" in which message +// boundaries are defined by preceding each message with its length as +// 4-byte unsigned integer so this is the largest value that can be +// represented. Good luck generating a serialized message that large, +// the practical write limit is likely to be dictated by available memory. +const MAX_READ = 1024 * 1024; +const MAX_WRITE = 0xffffffff; + +// Preferences that can lower the message size limits above, +// used for testing the limits. +const PREF_MAX_READ = "webextensions.native-messaging.max-input-message-bytes"; +const PREF_MAX_WRITE = + "webextensions.native-messaging.max-output-message-bytes"; + +XPCOMUtils.defineLazyPreferenceGetter(lazy, "maxRead", PREF_MAX_READ, MAX_READ); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "maxWrite", + PREF_MAX_WRITE, + MAX_WRITE +); + +export class NativeApp extends EventEmitter { + /** + * @param {BaseContext} context The context that initiated the native app. + * @param {string} application The identifier of the native app. + */ + constructor(context, application) { + super(); + + this.context = context; + this.name = application; + + // We want a close() notification when the window is destroyed. + this.context.callOnClose(this); + + this.proc = null; + this.readPromise = null; + this.sendQueue = []; + this.writePromise = null; + this.cleanupStarted = false; + + this.startupPromise = lazy.NativeManifests.lookupManifest( + "stdio", + application, + context + ) + .then(hostInfo => { + // Report a generic error to not leak information about whether a native + // application is installed to addons that do not have the right permission. + if (!hostInfo) { + throw new ExtensionError(`No such native application ${application}`); + } + + let command = hostInfo.manifest.path; + if (AppConstants.platform == "win") { + // Normalize in case the extension used / instead of \. + command = command.replaceAll("/", "\\"); + + if (!PathUtils.isAbsolute(command)) { + // Note: hostInfo.path is an absolute path to the manifest. + const parentPath = PathUtils.parent( + hostInfo.path.replaceAll("/", "\\") + ); + // PathUtils.joinRelative cannot be used because it throws for "..". + // but command is allowed to contain ".." to traverse the directory. + command = `${parentPath}\\${command}`; + } + } else if (!PathUtils.isAbsolute(command)) { + // Only windows supports relative paths. + throw new Error( + "NativeApp requires absolute path to command on this platform" + ); + } + + let subprocessOpts = { + command: command, + arguments: [hostInfo.path, context.extension.id], + workdir: PathUtils.parent(command), + stderr: "pipe", + disclaim: true, + }; + + return lazy.Subprocess.call(subprocessOpts); + }) + .then(proc => { + this.startupPromise = null; + this.proc = proc; + this._startRead(); + this._startWrite(); + this._startStderrRead(); + }) + .catch(err => { + this.startupPromise = null; + Cu.reportError(err instanceof Error ? err : err.message); + this._cleanup(err); + }); + } + + /** + * Open a connection to a native messaging host. + * + * @param {number} portId A unique internal ID that identifies the port. + * @param {import("ExtensionParent.sys.mjs").NativeMessenger} port Parent NativeMessenger used to send messages. + * @returns {import("ExtensionParent.sys.mjs").ParentPort} + */ + onConnect(portId, port) { + // eslint-disable-next-line + this.on("message", (_, message) => { + port.sendPortMessage( + portId, + new StructuredCloneHolder( + `NativeMessaging/onConnect/${this.name}`, + null, + message + ) + ); + }); + this.once("disconnect", (_, error) => { + port.sendPortDisconnect(portId, error && new ClonedErrorHolder(error)); + }); + return { + onPortMessage: holder => this.send(holder), + onPortDisconnect: () => this.close(), + }; + } + + /** + * @param {BaseContext} context The scope from where `message` originates. + * @param {*} message A message from the extension, meant for a native app. + * @returns {ArrayBuffer} An ArrayBuffer that can be sent to the native app. + */ + static encodeMessage(context, message) { + message = context.jsonStringify(message); + let buffer = new TextEncoder().encode(message).buffer; + if (buffer.byteLength > lazy.maxWrite) { + throw new context.Error("Write too big"); + } + return buffer; + } + + // A port is definitely "alive" if this.proc is non-null. But we have + // to provide a live port object immediately when connecting so we also + // need to consider a port alive if proc is null but the startupPromise + // is still pending. + get _isDisconnected() { + return !this.proc && !this.startupPromise; + } + + _startRead() { + if (this.readPromise) { + throw new Error("Entered _startRead() while readPromise is non-null"); + } + this.readPromise = this.proc.stdout + .readUint32() + .then(len => { + if (len > lazy.maxRead) { + throw new ExtensionError( + `Native application tried to send a message of ${len} bytes, which exceeds the limit of ${lazy.maxRead} bytes.` + ); + } + return this.proc.stdout.readJSON(len); + }) + .then(msg => { + this.emit("message", msg); + this.readPromise = null; + this._startRead(); + }) + .catch(err => { + if (err.errorCode != lazy.Subprocess.ERROR_END_OF_FILE) { + Cu.reportError(err instanceof Error ? err : err.message); + } + this._cleanup(err); + }); + } + + _startWrite() { + if (!this.sendQueue.length) { + return; + } + + if (this.writePromise) { + throw new Error("Entered _startWrite() while writePromise is non-null"); + } + + let buffer = this.sendQueue.shift(); + let uintArray = Uint32Array.of(buffer.byteLength); + + this.writePromise = Promise.all([ + this.proc.stdin.write(uintArray.buffer), + this.proc.stdin.write(buffer), + ]) + .then(() => { + this.writePromise = null; + this._startWrite(); + }) + .catch(err => { + Cu.reportError(err.message); + this._cleanup(err); + }); + } + + _startStderrRead() { + let proc = this.proc; + let app = this.name; + (async function () { + let partial = ""; + while (true) { + let data = await proc.stderr.readString(); + if (!data.length) { + // We have hit EOF, just stop reading + if (partial) { + Services.console.logStringMessage( + `stderr output from native app ${app}: ${partial}` + ); + } + break; + } + + let lines = data.split(/\r?\n/); + lines[0] = partial + lines[0]; + partial = lines.pop(); + + for (let line of lines) { + Services.console.logStringMessage( + `stderr output from native app ${app}: ${line}` + ); + } + } + })(); + } + + send(holder) { + if (this._isDisconnected) { + throw new ExtensionError("Attempt to postMessage on disconnected port"); + } + let msg = holder.deserialize(globalThis); + if (Cu.getClassName(msg, true) != "ArrayBuffer") { + // This error cannot be triggered by extensions; it indicates an error in + // our implementation. + throw new Error( + "The message to the native messaging host is not an ArrayBuffer" + ); + } + + let buffer = msg; + + if (buffer.byteLength > lazy.maxWrite) { + throw new ExtensionError("Write too big"); + } + + this.sendQueue.push(buffer); + if (!this.startupPromise && !this.writePromise) { + this._startWrite(); + } + } + + // Shut down the native application and (by default) signal to the extension + // that the connect has been disconnected. + async _cleanup(err, fromExtension = false) { + if (this.cleanupStarted) { + return; + } + this.cleanupStarted = true; + this.context.forgetOnClose(this); + + if (!fromExtension) { + if (err && err.errorCode == lazy.Subprocess.ERROR_END_OF_FILE) { + err = null; + } + this.emit("disconnect", err); + } + + await this.startupPromise; + + if (!this.proc) { + // Failed to initialize proc in the constructor. + return; + } + + // To prevent an uncooperative process from blocking shutdown, we take the + // following actions, and wait for GRACEFUL_SHUTDOWN_TIME in between. + // + // 1. Allow exit by closing the stdin pipe. + // 2. Allow exit by a kill signal. + // 3. Allow exit by forced kill signal. + // 4. Give up and unblock shutdown despite the process still being alive. + + // Close the stdin stream and allow the process to exit on its own. + // proc.wait() below will resolve once the process has exited gracefully. + this.proc.stdin.close().catch(err => { + if (err.errorCode != lazy.Subprocess.ERROR_END_OF_FILE) { + Cu.reportError(err); + } + }); + let exitPromise = Promise.race([ + // 1. Allow the process to exit on its own after closing stdin. + this.proc.wait().then(() => { + this.proc = null; + }), + promiseTimeout(GRACEFUL_SHUTDOWN_TIME).then(() => { + if (this.proc) { + // 2. Kill the process gracefully. 3. Force kill after a timeout. + this.proc.kill(GRACEFUL_SHUTDOWN_TIME); + + // 4. If the process is still alive after a kill + timeout followed + // by a forced kill + timeout, give up and just resolve exitPromise. + // + // Note that waiting for just one interval is not enough, because the + // `proc.kill()` is asynchronous, so we need to wait a bit after the + // kill signal has been sent. + return promiseTimeout(2 * GRACEFUL_SHUTDOWN_TIME); + } + }), + ]); + + lazy.AsyncShutdown.profileBeforeChange.addBlocker( + `Native Messaging: Wait for application ${this.name} to exit`, + exitPromise + ); + } + + // Called when the Context or Port is closed. + close() { + this._cleanup(null, true); + } + + sendMessage(holder) { + let responsePromise = new Promise((resolve, reject) => { + this.once("message", (what, msg) => { + resolve(msg); + }); + this.once("disconnect", (what, err) => { + reject(err); + }); + }); + + let result = this.startupPromise.then(() => { + // Skip .send() if _cleanup() has been called already; + // otherwise the error passed to _cleanup/"disconnect" would be hidden by the + // "Attempt to postMessage on disconnected port" error from this.send(). + if (!this.cleanupStarted) { + this.send(holder); + } + return responsePromise; + }); + + result.then( + () => { + this._cleanup(); + }, + () => { + // Prevent the response promise from being reported as an + // unchecked rejection if the startup promise fails. + responsePromise.catch(() => {}); + + this._cleanup(); + } + ); + + return result; + } +} diff --git a/toolkit/components/extensions/PExtensions.ipdl b/toolkit/components/extensions/PExtensions.ipdl new file mode 100644 index 0000000000..ad5a0c993a --- /dev/null +++ b/toolkit/components/extensions/PExtensions.ipdl @@ -0,0 +1,61 @@ +/* -*- Mode: C++; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 8 -*- */ +/* vim: set sw=4 ts=8 et tw=80 ft=cpp : */ +/* 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/. */ + +include protocol PContent; +include protocol PInProcess; +include DOMTypes; +include "mozilla/ipc/URIUtils.h"; + +namespace mozilla { +namespace extensions { + +struct FrameTransitionData +{ + bool clientRedirect; + bool formSubmit; + bool forwardBack; + bool reload; + bool serverRedirect; +}; + +/** + * A generic protocol used by the extension framework for process-level IPC. A + * child instance is created at startup in the parent process and each content + * child process, which can be accessed via + * `mozilla::extensions::ExtensionsChild::Get()`. + */ +protocol PExtensions +{ + manager PContent or PInProcess; + + parent: + async __delete__(); + + async DocumentChange(MaybeDiscardedBrowsingContext bc, + FrameTransitionData transitionData, + nullable nsIURI location); + + async HistoryChange(MaybeDiscardedBrowsingContext bc, + FrameTransitionData transitionData, + nullable nsIURI location, + bool isHistoryStateUpdated, + bool isReferenceFragmentUpdated); + + async StateChange(MaybeDiscardedBrowsingContext bc, + nullable nsIURI requestURI, + nsresult status, + uint32_t stateFlags); + + async CreatedNavigationTarget(MaybeDiscardedBrowsingContext bc, + MaybeDiscardedBrowsingContext sourceBC, + nsCString url); + + async DOMContentLoaded(MaybeDiscardedBrowsingContext bc, + nullable nsIURI documentURI); +}; + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/ProxyChannelFilter.sys.mjs b/toolkit/components/extensions/ProxyChannelFilter.sys.mjs new file mode 100644 index 0000000000..2f7f8cb113 --- /dev/null +++ b/toolkit/components/extensions/ProxyChannelFilter.sys.mjs @@ -0,0 +1,427 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", +}); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "ProxyService", + "@mozilla.org/network/protocol-proxy-service;1", + "nsIProtocolProxyService" +); + +ChromeUtils.defineLazyGetter(lazy, "tabTracker", () => { + return lazy.ExtensionParent.apiManager.global.tabTracker; +}); +ChromeUtils.defineLazyGetter( + lazy, + "getCookieStoreIdForOriginAttributes", + () => { + return lazy.ExtensionParent.apiManager.global + .getCookieStoreIdForOriginAttributes; + } +); + +// DNS is resolved on the SOCKS proxy server. +const { TRANSPARENT_PROXY_RESOLVES_HOST } = Ci.nsIProxyInfo; + +// The length of time (seconds) to wait for a proxy to resolve before ignoring it. +const PROXY_TIMEOUT_SEC = 10; + +const { ExtensionError } = ExtensionUtils; + +const PROXY_TYPES = Object.freeze({ + DIRECT: "direct", + HTTPS: "https", + PROXY: "http", // Synonym for PROXY_TYPES.HTTP + HTTP: "http", + SOCKS: "socks", // SOCKS5 + SOCKS4: "socks4", +}); + +const ProxyInfoData = { + validate(proxyData) { + if (proxyData.type && proxyData.type.toLowerCase() === "direct") { + return { type: proxyData.type }; + } + for (let prop of [ + "type", + "host", + "port", + "username", + "password", + "proxyDNS", + "failoverTimeout", + "proxyAuthorizationHeader", + "connectionIsolationKey", + ]) { + this[prop](proxyData); + } + return proxyData; + }, + + type(proxyData) { + let { type } = proxyData; + if ( + typeof type !== "string" || + !PROXY_TYPES.hasOwnProperty(type.toUpperCase()) + ) { + throw new ExtensionError( + `ProxyInfoData: Invalid proxy server type: "${type}"` + ); + } + proxyData.type = PROXY_TYPES[type.toUpperCase()]; + }, + + host(proxyData) { + let { host } = proxyData; + if (typeof host !== "string" || host.includes(" ")) { + throw new ExtensionError( + `ProxyInfoData: Invalid proxy server host: "${host}"` + ); + } + if (!host.length) { + throw new ExtensionError( + "ProxyInfoData: Proxy server host cannot be empty" + ); + } + proxyData.host = host; + }, + + port(proxyData) { + let port = Number.parseInt(proxyData.port, 10); + if (!Number.isInteger(port)) { + throw new ExtensionError( + `ProxyInfoData: Invalid proxy server port: "${port}"` + ); + } + + if (port < 1 || port > 0xffff) { + throw new ExtensionError( + `ProxyInfoData: Proxy server port ${port} outside range 1 to 65535` + ); + } + proxyData.port = port; + }, + + username(proxyData) { + let { username } = proxyData; + if (username !== undefined && typeof username !== "string") { + throw new ExtensionError( + `ProxyInfoData: Invalid proxy server username: "${username}"` + ); + } + }, + + password(proxyData) { + let { password } = proxyData; + if (password !== undefined && typeof password !== "string") { + throw new ExtensionError( + `ProxyInfoData: Invalid proxy server password: "${password}"` + ); + } + }, + + proxyDNS(proxyData) { + let { proxyDNS, type } = proxyData; + if (proxyDNS !== undefined) { + if (typeof proxyDNS !== "boolean") { + throw new ExtensionError( + `ProxyInfoData: Invalid proxyDNS value: "${proxyDNS}"` + ); + } + if ( + proxyDNS && + type !== PROXY_TYPES.SOCKS && + type !== PROXY_TYPES.SOCKS4 + ) { + throw new ExtensionError( + `ProxyInfoData: proxyDNS can only be true for SOCKS proxy servers` + ); + } + } + }, + + failoverTimeout(proxyData) { + let { failoverTimeout } = proxyData; + if ( + failoverTimeout !== undefined && + (!Number.isInteger(failoverTimeout) || failoverTimeout < 1) + ) { + throw new ExtensionError( + `ProxyInfoData: Invalid failover timeout: "${failoverTimeout}"` + ); + } + }, + + proxyAuthorizationHeader(proxyData) { + let { proxyAuthorizationHeader, type } = proxyData; + if (proxyAuthorizationHeader === undefined) { + return; + } + if (typeof proxyAuthorizationHeader !== "string") { + throw new ExtensionError( + `ProxyInfoData: Invalid proxy server authorization header: "${proxyAuthorizationHeader}"` + ); + } + if (type !== "https") { + throw new ExtensionError( + `ProxyInfoData: ProxyAuthorizationHeader requires type "https"` + ); + } + }, + + connectionIsolationKey(proxyData) { + let { connectionIsolationKey } = proxyData; + if ( + connectionIsolationKey !== undefined && + typeof connectionIsolationKey !== "string" + ) { + throw new ExtensionError( + `ProxyInfoData: Invalid proxy connection isolation key: "${connectionIsolationKey}"` + ); + } + }, + + createProxyInfoFromData( + policy, + proxyDataList, + defaultProxyInfo, + proxyDataListIndex = 0 + ) { + if (proxyDataListIndex >= proxyDataList.length) { + return defaultProxyInfo; + } + let proxyData = proxyDataList[proxyDataListIndex]; + if (proxyData == null) { + return null; + } + let { + type, + host, + port, + username, + password, + proxyDNS, + failoverTimeout, + proxyAuthorizationHeader, + connectionIsolationKey, + } = ProxyInfoData.validate(proxyData); + if (type === PROXY_TYPES.DIRECT && defaultProxyInfo) { + return defaultProxyInfo; + } + let failoverProxy = this.createProxyInfoFromData( + policy, + proxyDataList, + defaultProxyInfo, + proxyDataListIndex + 1 + ); + + let proxyInfo; + if (type === PROXY_TYPES.SOCKS || type === PROXY_TYPES.SOCKS4) { + proxyInfo = lazy.ProxyService.newProxyInfoWithAuth( + type, + host, + port, + username, + password, + proxyAuthorizationHeader, + connectionIsolationKey, + proxyDNS ? TRANSPARENT_PROXY_RESOLVES_HOST : 0, + failoverTimeout ? failoverTimeout : PROXY_TIMEOUT_SEC, + failoverProxy + ); + } else { + proxyInfo = lazy.ProxyService.newProxyInfo( + type, + host, + port, + proxyAuthorizationHeader, + connectionIsolationKey, + proxyDNS ? TRANSPARENT_PROXY_RESOLVES_HOST : 0, + failoverTimeout ? failoverTimeout : PROXY_TIMEOUT_SEC, + failoverProxy + ); + } + proxyInfo.sourceId = policy.id; + return proxyInfo; + }, +}; + +function normalizeFilter(filter) { + if (!filter) { + filter = {}; + } + + return { + urls: filter.urls || null, + types: filter.types || null, + tabId: filter.tabId ?? null, + windowId: filter.windowId ?? null, + incognito: filter.incognito ?? null, + }; +} + +export class ProxyChannelFilter { + constructor(context, extension, listener, filter, extraInfoSpec) { + this.context = context; + this.extension = extension; + this.filter = normalizeFilter(filter); + this.listener = listener; + this.extraInfoSpec = extraInfoSpec || []; + + lazy.ProxyService.registerChannelFilter( + this /* nsIProtocolProxyChannelFilter aFilter */, + 0 /* unsigned long aPosition */ + ); + } + + // Originally duplicated from WebRequest.jsm with small changes. Keep this + // in sync with WebRequest.jsm as well as parent/ext-webRequest.js when + // apropiate. + getRequestData(channel, extraData) { + let originAttributes = channel.loadInfo?.originAttributes; + let data = { + requestId: String(channel.id), + url: channel.finalURL, + method: channel.method, + type: channel.type, + fromCache: !!channel.fromCache, + incognito: originAttributes?.privateBrowsingId > 0, + thirdParty: channel.thirdParty, + + originUrl: channel.originURL || undefined, + documentUrl: channel.documentURL || undefined, + + frameId: channel.frameId, + parentFrameId: channel.parentFrameId, + + frameAncestors: channel.frameAncestors || undefined, + + timeStamp: Date.now(), + + ...extraData, + }; + if (originAttributes) { + data.cookieStoreId = + lazy.getCookieStoreIdForOriginAttributes(originAttributes); + } + if (this.extraInfoSpec.includes("requestHeaders")) { + data.requestHeaders = channel.getRequestHeaders(); + } + if (channel.urlClassification) { + data.urlClassification = { + firstParty: channel.urlClassification.firstParty.filter( + c => !c.startsWith("socialtracking") + ), + thirdParty: channel.urlClassification.thirdParty.filter( + c => !c.startsWith("socialtracking") + ), + }; + } + return data; + } + + /** + * This method (which is required by the nsIProtocolProxyService interface) + * is called to apply proxy filter rules for the given URI and proxy object + * (or list of proxy objects). + * + * @param {nsIChannel} channel The channel for which these proxy settings apply. + * @param {nsIProxyInfo} defaultProxyInfo The proxy (or list of proxies) that + * would be used by default for the given URI. This may be null. + * @param {nsIProxyProtocolFilterResult} proxyFilter + */ + async applyFilter(channel, defaultProxyInfo, proxyFilter) { + let proxyInfo; + try { + let wrapper = ChannelWrapper.get(channel); + + let browserData = { tabId: -1, windowId: -1 }; + if (wrapper.browserElement) { + browserData = lazy.tabTracker.getBrowserData(wrapper.browserElement); + } + + let { filter, extension } = this; + if (filter.tabId != null && browserData.tabId !== filter.tabId) { + return; + } + if (filter.windowId != null && browserData.windowId !== filter.windowId) { + return; + } + if ( + extension.userContextIsolation && + !extension.canAccessContainer( + channel.loadInfo?.originAttributes.userContextId + ) + ) { + return; + } + + let { policy } = this.extension; + if (wrapper.matches(filter, policy, { isProxy: true })) { + let data = this.getRequestData(wrapper, { tabId: browserData.tabId }); + + let ret = await this.listener(data); + if (ret == null) { + // If ret undefined or null, fall through to the `finally` block to apply the proxy result. + proxyInfo = ret; + return; + } + // We only accept proxyInfo objects, not the PAC strings. ProxyInfoData will + // accept either, so we want to enforce the limit here. + if (typeof ret !== "object") { + throw new ExtensionError( + "ProxyInfoData: proxyData must be an object or array of objects" + ); + } + // We allow the call to return either a single proxyInfo or an array of proxyInfo. + if (!Array.isArray(ret)) { + ret = [ret]; + } + proxyInfo = ProxyInfoData.createProxyInfoFromData( + policy, + ret, + defaultProxyInfo + ); + } + } catch (e) { + // We need to normalize errors to dispatch them to the extension handler. If + // we have not started up yet, we'll just log those to the console. + if (!this.context) { + this.extension.logError(`proxy-error before extension startup: ${e}`); + return; + } + let error = this.context.normalizeError(e); + this.extension.emit("proxy-error", { + message: error.message, + fileName: error.fileName, + lineNumber: error.lineNumber, + stack: error.stack, + }); + } finally { + // We must call onProxyFilterResult. proxyInfo may be null or nsIProxyInfo. + // defaultProxyInfo will be null unless a prior proxy handler has set something. + // If proxyInfo is null, that removes any prior proxy config. This allows a + // proxy extension to override higher level (e.g. prefs) config under certain + // circumstances. + proxyFilter.onProxyFilterResult( + proxyInfo !== undefined ? proxyInfo : defaultProxyInfo + ); + } + } + + destroy() { + lazy.ProxyService.unregisterFilter(this); + } +} diff --git a/toolkit/components/extensions/Schemas.sys.mjs b/toolkit/components/extensions/Schemas.sys.mjs new file mode 100644 index 0000000000..9107e6a347 --- /dev/null +++ b/toolkit/components/extensions/Schemas.sys.mjs @@ -0,0 +1,3942 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +var { DefaultMap, DefaultWeakMap } = ExtensionUtils; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "contentPolicyService", + "@mozilla.org/addons/content-policy;1", + "nsIAddonContentPolicy" +); + +ChromeUtils.defineLazyGetter( + lazy, + "StartupCache", + () => lazy.ExtensionParent.StartupCache +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "treatWarningsAsErrors", + "extensions.webextensions.warnings-as-errors", + false +); + +const KEY_CONTENT_SCHEMAS = "extensions-framework/schemas/content"; +const KEY_PRIVILEGED_SCHEMAS = "extensions-framework/schemas/privileged"; + +const MIN_MANIFEST_VERSION = 2; +const MAX_MANIFEST_VERSION = 3; + +const { DEBUG } = AppConstants; + +const isParentProcess = + Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT; + +function readJSON(url) { + return new Promise((resolve, reject) => { + lazy.NetUtil.asyncFetch( + { uri: url, loadUsingSystemPrincipal: true }, + (inputStream, status) => { + if (!Components.isSuccessCode(status)) { + // Convert status code to a string + let e = Components.Exception("", status); + reject(new Error(`Error while loading '${url}' (${e.name})`)); + return; + } + try { + let text = lazy.NetUtil.readInputStreamToString( + inputStream, + inputStream.available() + ); + + // Chrome JSON files include a license comment that we need to + // strip off for this to be valid JSON. As a hack, we just + // look for the first '[' character, which signals the start + // of the JSON content. + let index = text.indexOf("["); + text = text.slice(index); + + resolve(JSON.parse(text)); + } catch (e) { + reject(e); + } + } + ); + }); +} + +function stripDescriptions(json, stripThis = true) { + if (Array.isArray(json)) { + for (let i = 0; i < json.length; i++) { + if (typeof json[i] === "object" && json[i] !== null) { + json[i] = stripDescriptions(json[i]); + } + } + return json; + } + + let result = {}; + + // Objects are handled much more efficiently, both in terms of memory and + // CPU, if they have the same shape as other objects that serve the same + // purpose. So, normalize the order of properties to increase the chances + // that the majority of schema objects wind up in large shape groups. + for (let key of Object.keys(json).sort()) { + if (stripThis && key === "description" && typeof json[key] === "string") { + continue; + } + + if (typeof json[key] === "object" && json[key] !== null) { + result[key] = stripDescriptions(json[key], key !== "properties"); + } else { + result[key] = json[key]; + } + } + + return result; +} + +function blobbify(json) { + // We don't actually use descriptions at runtime, and they make up about a + // third of the size of our structured clone data, so strip them before + // blobbifying. + json = stripDescriptions(json); + + return new StructuredCloneHolder("Schemas/blobbify", null, json); +} + +async function readJSONAndBlobbify(url) { + let json = await readJSON(url); + + return blobbify(json); +} + +/** + * Defines a lazy getter for the given property on the given object. Any + * security wrappers are waived on the object before the property is + * defined, and the getter and setter methods are wrapped for the target + * scope. + * + * The given getter function is guaranteed to be called only once, even + * if the target scope retrieves the wrapped getter from the property + * descriptor and calls it directly. + * + * @param {object} object + * The object on which to define the getter. + * @param {string | symbol} prop + * The property name for which to define the getter. + * @param {Function} getter + * The function to call in order to generate the final property + * value. + */ +function exportLazyGetter(object, prop, getter) { + object = ChromeUtils.waiveXrays(object); + + let redefine = value => { + if (value === undefined) { + delete object[prop]; + } else { + Object.defineProperty(object, prop, { + enumerable: true, + configurable: true, + writable: true, + value, + }); + } + + getter = null; + + return value; + }; + + Object.defineProperty(object, prop, { + enumerable: true, + configurable: true, + + get: Cu.exportFunction(function () { + return redefine(getter.call(this)); + }, object), + + set: Cu.exportFunction(value => { + redefine(value); + }, object), + }); +} + +/** + * Defines a lazily-instantiated property descriptor on the given + * object. Any security wrappers are waived on the object before the + * property is defined. + * + * The given getter function is guaranteed to be called only once, even + * if the target scope retrieves the wrapped getter from the property + * descriptor and calls it directly. + * + * @param {object} object + * The object on which to define the getter. + * @param {string | symbol} prop + * The property name for which to define the getter. + * @param {Function} getter + * The function to call in order to generate the final property + * descriptor object. This will be called, and the property + * descriptor installed on the object, the first time the + * property is written or read. The function may return + * undefined, which will cause the property to be deleted. + */ +function exportLazyProperty(object, prop, getter) { + object = ChromeUtils.waiveXrays(object); + + let redefine = obj => { + let desc = getter.call(obj); + getter = null; + + delete object[prop]; + if (desc) { + let defaults = { + configurable: true, + enumerable: true, + }; + + if (!desc.set && !desc.get) { + defaults.writable = true; + } + + Object.defineProperty(object, prop, Object.assign(defaults, desc)); + } + }; + + Object.defineProperty(object, prop, { + enumerable: true, + configurable: true, + + get: Cu.exportFunction(function () { + redefine(this); + return object[prop]; + }, object), + + set: Cu.exportFunction(function (value) { + redefine(this); + object[prop] = value; + }, object), + }); +} + +const POSTPROCESSORS = { + convertImageDataToURL(imageData, context) { + let document = context.cloneScope.document; + let canvas = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "canvas" + ); + canvas.width = imageData.width; + canvas.height = imageData.height; + canvas.getContext("2d").putImageData(imageData, 0, 0); + + return canvas.toDataURL("image/png"); + }, + webRequestBlockingPermissionRequired(string, context) { + if (string === "blocking" && !context.hasPermission("webRequestBlocking")) { + throw new context.cloneScope.Error( + "Using webRequest.addListener with the " + + "blocking option requires the 'webRequestBlocking' permission." + ); + } + + return string; + }, + requireBackgroundServiceWorkerEnabled(value, context) { + if (WebExtensionPolicy.backgroundServiceWorkerEnabled) { + return value; + } + + // Add an error to the manifest validations and throw the + // same error. + const msg = "background.service_worker is currently disabled"; + context.logError(context.makeError(msg)); + throw new Error(msg); + }, + + manifestVersionCheck(value, context) { + if ( + value == 2 || + (value == 3 && + Services.prefs.getBoolPref("extensions.manifestV3.enabled", false)) + ) { + return value; + } + const msg = `Unsupported manifest version: ${value}`; + context.logError(context.makeError(msg)); + throw new Error(msg); + }, + + webAccessibleMatching(value, context) { + // Ensure each object has at least one of matches or extension_ids array. + for (let obj of value) { + if (!obj.matches && !obj.extension_ids) { + const msg = `web_accessible_resources requires one of "matches" or "extension_ids"`; + context.logError(context.makeError(msg)); + throw new Error(msg); + } + } + return value; + }, +}; + +// Parses a regular expression, with support for the Python extended +// syntax that allows setting flags by including the string (?im) +function parsePattern(pattern) { + let flags = ""; + let match = /^\(\?([im]*)\)(.*)/.exec(pattern); + if (match) { + [, flags, pattern] = match; + } + return new RegExp(pattern, flags); +} + +function getValueBaseType(value) { + let type = typeof value; + switch (type) { + case "object": + if (value === null) { + return "null"; + } + if (Array.isArray(value)) { + return "array"; + } + break; + + case "number": + if (value % 1 === 0) { + return "integer"; + } + } + return type; +} + +// Methods of Context that are used by Schemas.normalize. These methods can be +// overridden at the construction of Context. +const CONTEXT_FOR_VALIDATION = ["checkLoadURL", "hasPermission", "logError"]; + +// Methods of Context that are used by Schemas.inject. +// Callers of Schemas.inject should implement all of these methods. +const CONTEXT_FOR_INJECTION = [ + ...CONTEXT_FOR_VALIDATION, + "getImplementation", + "isPermissionRevokable", + "shouldInject", +]; + +// If the message is a function, call it and return the result. +// Otherwise, assume it's a string. +function forceString(msg) { + if (typeof msg === "function") { + return msg(); + } + return msg; +} + +/** + * A context for schema validation and error reporting. This class is only used + * internally within Schemas. + */ +class Context { + /** + * @param {object} params Provides the implementation of this class. + * @param {Array} overridableMethods + */ + constructor(params, overridableMethods = CONTEXT_FOR_VALIDATION) { + this.params = params; + + if (typeof params.manifestVersion !== "number") { + throw new Error( + `Unexpected params.manifestVersion value: ${params.manifestVersion}` + ); + } + + this.path = []; + this.preprocessors = { + localize(value, context) { + return value; + }, + ...params.preprocessors, + }; + + this.postprocessors = POSTPROCESSORS; + this.isChromeCompat = params.isChromeCompat ?? false; + this.manifestVersion = params.manifestVersion; + + this.currentChoices = new Set(); + this.choicePathIndex = 0; + + for (let method of overridableMethods) { + if (method in params) { + this[method] = params[method].bind(params); + } + } + } + + get choicePath() { + let path = this.path.slice(this.choicePathIndex); + return path.join("."); + } + + get cloneScope() { + return this.params.cloneScope || undefined; + } + + get url() { + return this.params.url; + } + + get principal() { + return ( + this.params.principal || + Services.scriptSecurityManager.createNullPrincipal({}) + ); + } + + /** + * Checks whether `url` may be loaded by the extension in this context. + * + * @param {string} url The URL that the extension wished to load. + * @returns {boolean} Whether the context may load `url`. + */ + checkLoadURL(url) { + let ssm = Services.scriptSecurityManager; + try { + ssm.checkLoadURIWithPrincipal( + this.principal, + Services.io.newURI(url), + ssm.DISALLOW_INHERIT_PRINCIPAL + ); + } catch (e) { + return false; + } + return true; + } + + /** + * Checks whether this context has the given permission. + * + * @param {string} permission + * The name of the permission to check. + * + * @returns {boolean} True if the context has the given permission. + */ + hasPermission(permission) { + return false; + } + + /** + * Checks whether the given permission can be dynamically revoked or + * granted. + * + * @param {string} permission + * The name of the permission to check. + * + * @returns {boolean} True if the given permission is revokable. + */ + isPermissionRevokable(permission) { + return false; + } + + /** + * Returns an error result object with the given message, for return + * by Type normalization functions. + * + * If the context has a `currentTarget` value, this is prepended to + * the message to indicate the location of the error. + * + * @param {string | Function} errorMessage + * The error message which will be displayed when this is the + * only possible matching schema. If a function is passed, it + * will be evaluated when the error string is first needed, and + * must return a string. + * @param {string | Function} choicesMessage + * The message describing the valid what constitutes a valid + * value for this schema, which will be displayed when multiple + * schema choices are available and none match. + * + * A caller may pass `null` to prevent a choice from being + * added, but this should *only* be done from code processing a + * choices type. + * @param {boolean} [warning = false] + * If true, make message prefixed `Warning`. If false, make message + * prefixed `Error` + * @returns {object} + */ + error(errorMessage, choicesMessage = undefined, warning = false) { + if (choicesMessage !== null) { + let { choicePath } = this; + if (choicePath) { + choicesMessage = `.${choicePath} must ${choicesMessage}`; + } + + this.currentChoices.add(choicesMessage); + } + + if (this.currentTarget) { + let { currentTarget } = this; + return { + error: () => + `${ + warning ? "Warning" : "Error" + } processing ${currentTarget}: ${forceString(errorMessage)}`, + }; + } + return { error: errorMessage }; + } + + /** + * Creates an `Error` object belonging to the current unprivileged + * scope. If there is no unprivileged scope associated with this + * context, the message is returned as a string. + * + * If the context has a `currentTarget` value, this is prepended to + * the message, in the same way as for the `error` method. + * + * @param {string} message + * @param {object} [options] + * @param {boolean} [options.warning = false] + * @returns {Error} + */ + makeError(message, { warning = false } = {}) { + let error = forceString(this.error(message, null, warning).error); + if (this.cloneScope) { + return new this.cloneScope.Error(error); + } + return error; + } + + /** + * Logs the given error to the console. May be overridden to enable + * custom logging. + * + * @param {Error|string} error + */ + logError(error) { + if (this.cloneScope) { + Cu.reportError( + // Error objects logged using Cu.reportError are not associated + // to the related innerWindowID. This results in a leaked docshell + // since consoleService cannot release the error object when the + // extension global is destroyed. + typeof error == "string" ? error : String(error), + // Report the error with the appropriate stack trace when the + // is related to an actual extension global (instead of being + // related to a manifest validation). + this.principal && ChromeUtils.getCallerLocation(this.principal) + ); + } else { + Cu.reportError(error); + } + } + + /** + * Logs a warning. An error might be thrown when we treat warnings as errors. + * + * @param {string} warningMessage + */ + logWarning(warningMessage) { + let error = this.makeError(warningMessage, { warning: true }); + this.logError(error); + + if (lazy.treatWarningsAsErrors) { + // This pref is false by default, and true by default in tests to + // discourage the use of deprecated APIs in our unit tests. + // If a warning is an expected part of a test, temporarily set the pref + // to false, e.g. with the ExtensionTestUtils.failOnSchemaWarnings helper. + Services.console.logStringMessage( + "Treating warning as error because the preference " + + "extensions.webextensions.warnings-as-errors is set to true" + ); + if (typeof error === "string") { + error = new Error(error); + } + throw error; + } + } + + /** + * Returns the name of the value currently being normalized. For a + * nested object, this is usually approximately equivalent to the + * JavaScript property accessor for that property. Given: + * + * { foo: { bar: [{ baz: x }] } } + * + * When processing the value for `x`, the currentTarget is + * 'foo.bar.0.baz' + */ + get currentTarget() { + return this.path.join("."); + } + + /** + * Executes the given callback, and returns an array of choice strings + * passed to {@see #error} during its execution. + * + * @param {Function} callback + * @returns {object} + * An object with a `result` property containing the return + * value of the callback, and a `choice` property containing + * an array of choices. + */ + withChoices(callback) { + let { currentChoices, choicePathIndex } = this; + + let choices = new Set(); + this.currentChoices = choices; + this.choicePathIndex = this.path.length; + + try { + let result = callback(); + + return { result, choices }; + } finally { + this.currentChoices = currentChoices; + this.choicePathIndex = choicePathIndex; + + if (choices.size == 1) { + for (let choice of choices) { + currentChoices.add(choice); + } + } else if (choices.size) { + this.error(null, () => { + let array = Array.from(choices, forceString); + let n = array.length - 1; + array[n] = `or ${array[n]}`; + + return `must either [${array.join(", ")}]`; + }); + } + } + } + + /** + * Appends the given component to the `currentTarget` path to indicate + * that it is being processed, calls the given callback function, and + * then restores the original path. + * + * This is used to identify the path of the property being processed + * when reporting type errors. + * + * @param {string} component + * @param {Function} callback + * @returns {*} + */ + withPath(component, callback) { + this.path.push(component); + try { + return callback(); + } finally { + this.path.pop(); + } + } + + matchManifestVersion(entry) { + let { manifestVersion } = this; + return ( + manifestVersion >= entry.min_manifest_version && + manifestVersion <= entry.max_manifest_version + ); + } +} + +/** + * Represents a schema entry to be injected into an object. Handles the + * injection, revocation, and permissions of said entry. + * + * @param {InjectionContext} context + * The injection context for the entry. + * @param {Entry} entry + * The entry to inject. + * @param {object} parentObject + * The object into which to inject this entry. + * @param {string} name + * The property name at which to inject this entry. + * @param {Array} path + * The full path from the root entry to this entry. + * @param {Entry} parentEntry + * The parent entry for the injected entry. + */ +class InjectionEntry { + constructor(context, entry, parentObj, name, path, parentEntry) { + this.context = context; + this.entry = entry; + this.parentObj = parentObj; + this.name = name; + this.path = path; + this.parentEntry = parentEntry; + + this.injected = null; + this.lazyInjected = null; + } + + /** + * @property {Array} allowedContexts + * The list of allowed contexts into which the entry may be + * injected. + */ + get allowedContexts() { + let { allowedContexts } = this.entry; + if (allowedContexts.length) { + return allowedContexts; + } + return this.parentEntry.defaultContexts; + } + + /** + * @property {boolean} isRevokable + * Returns true if this entry may be dynamically injected or + * revoked based on its permissions. + */ + get isRevokable() { + return ( + this.entry.permissions && + this.entry.permissions.some(perm => + this.context.isPermissionRevokable(perm) + ) + ); + } + + /** + * @property {boolean} hasPermission + * Returns true if the injection context currently has the + * appropriate permissions to access this entry. + */ + get hasPermission() { + return ( + !this.entry.permissions || + this.entry.permissions.some(perm => this.context.hasPermission(perm)) + ); + } + + /** + * @property {boolean} shouldInject + * Returns true if this entry should be injected in the given + * context, without respect to permissions. + */ + get shouldInject() { + return ( + this.context.matchManifestVersion(this.entry) && + this.context.shouldInject( + this.path.join("."), + this.name, + this.allowedContexts + ) + ); + } + + /** + * Revokes this entry, removing its property from its parent object, + * and invalidating its wrappers. + */ + revoke() { + if (this.lazyInjected) { + this.lazyInjected = false; + } else if (this.injected) { + if (this.injected.revoke) { + this.injected.revoke(); + } + + try { + let unwrapped = ChromeUtils.waiveXrays(this.parentObj); + delete unwrapped[this.name]; + } catch (e) { + Cu.reportError(e); + } + + let { value } = this.injected.descriptor; + if (value) { + this.context.revokeChildren(value); + } + + this.injected = null; + } + } + + /** + * Returns a property descriptor object for this entry, if it should + * be injected, or undefined if it should not. + * + * @returns {object?} + * A property descriptor object, or undefined if the property + * should be removed. + */ + getDescriptor() { + this.lazyInjected = false; + + if (this.injected) { + let path = [...this.path, this.name]; + throw new Error( + `Attempting to re-inject already injected entry: ${path.join(".")}` + ); + } + + if (!this.shouldInject) { + return; + } + + if (this.isRevokable) { + this.context.pendingEntries.add(this); + } + + if (!this.hasPermission) { + return; + } + + this.injected = this.entry.getDescriptor(this.path, this.context); + if (!this.injected) { + return undefined; + } + + return this.injected.descriptor; + } + + /** + * Injects a lazy property descriptor into the parent object which + * checks permissions and eligibility for injection the first time it + * is accessed. + */ + lazyInject() { + if (this.lazyInjected || this.injected) { + let path = [...this.path, this.name]; + throw new Error( + `Attempting to re-lazy-inject already injected entry: ${path.join(".")}` + ); + } + + this.lazyInjected = true; + exportLazyProperty(this.parentObj, this.name, () => { + if (this.lazyInjected) { + return this.getDescriptor(); + } + }); + } + + /** + * Injects or revokes this entry if its current state does not match + * the context's current permissions. + */ + permissionsChanged() { + if (this.injected) { + this.maybeRevoke(); + } else { + this.maybeInject(); + } + } + + maybeInject() { + if (!this.injected && !this.lazyInjected) { + this.lazyInject(); + } + } + + maybeRevoke() { + if (this.injected && !this.hasPermission) { + this.revoke(); + } + } +} + +/** + * Holds methods that run the actual implementation of the extension APIs. These + * methods are only called if the extension API invocation matches the signature + * as defined in the schema. Otherwise an error is reported to the context. + */ +class InjectionContext extends Context { + constructor(params, schemaRoot) { + super(params, CONTEXT_FOR_INJECTION); + + this.schemaRoot = schemaRoot; + + this.pendingEntries = new Set(); + this.children = new DefaultWeakMap(() => new Map()); + + this.injectedRoots = new Set(); + + if (params.setPermissionsChangedCallback) { + params.setPermissionsChangedCallback(this.permissionsChanged.bind(this)); + } + } + + /** + * Check whether the API should be injected. + * + * @abstract + * @param {string} namespace The namespace of the API. This may contain dots, + * e.g. in the case of "devtools.inspectedWindow". + * @param {string?} name The name of the property in the namespace. + * `null` if we are checking whether the namespace should be injected. + * @param {Array} allowedContexts A list of additional contexts in + * which this API should be available. May include any of: + * "main" - The main chrome browser process. + * "addon" - An addon process. + * "content" - A content process. + * @returns {boolean} Whether the API should be injected. + */ + shouldInject(namespace, name, allowedContexts) { + throw new Error("Not implemented"); + } + + /** + * Generate the implementation for `namespace`.`name`. + * + * @abstract + * @param {string} namespace The full path to the namespace of the API, minus + * the name of the method or property. E.g. "storage.local". + * @param {string} name The name of the method, property or event. + * @returns {SchemaAPIInterface} The implementation of the API. + */ + getImplementation(namespace, name) { + throw new Error("Not implemented"); + } + + /** + * Updates all injection entries which may need to be updated after a + * permission change, revoking or re-injecting them as necessary. + */ + permissionsChanged() { + for (let entry of this.pendingEntries) { + try { + entry.permissionsChanged(); + } catch (e) { + Cu.reportError(e); + } + } + } + + /** + * Recursively revokes all child injection entries of the given + * object. + * + * @param {object} object + * The object for which to invoke children. + */ + revokeChildren(object) { + if (!this.children.has(object)) { + return; + } + + let children = this.children.get(object); + for (let [name, entry] of children.entries()) { + try { + entry.revoke(); + } catch (e) { + Cu.reportError(e); + } + children.delete(name); + + // When we revoke children for an object, we consider that object + // dead. If the entry is ever reified again, a new object is + // created, with new child entries. + this.pendingEntries.delete(entry); + } + this.children.delete(object); + } + + _getInjectionEntry(entry, dest, name, path, parentEntry) { + let injection = new InjectionEntry( + this, + entry, + dest, + name, + path, + parentEntry + ); + + this.children.get(dest).set(name, injection); + + return injection; + } + + /** + * Returns the property descriptor for the given entry. + * + * @param {Entry} entry + * The entry instance to return a descriptor for. + * @param {object} dest + * The object into which this entry is being injected. + * @param {string} name + * The property name on the destination object where the entry + * will be injected. + * @param {Array} path + * The full path from the root injection object to this entry. + * @param {Partial} parentEntry + * The parent entry for this entry. + * + * @returns {object?} + * A property descriptor object, or null if the entry should + * not be injected. + */ + getDescriptor(entry, dest, name, path, parentEntry) { + let injection = this._getInjectionEntry( + entry, + dest, + name, + path, + parentEntry + ); + + return injection.getDescriptor(); + } + + /** + * Lazily injects the given entry into the given object. + * + * @param {Entry} entry + * The entry instance to lazily inject. + * @param {object} dest + * The object into which to inject this entry. + * @param {string} name + * The property name at which to inject the entry. + * @param {Array} path + * The full path from the root injection object to this entry. + * @param {Entry} parentEntry + * The parent entry for this entry. + */ + injectInto(entry, dest, name, path, parentEntry) { + let injection = this._getInjectionEntry( + entry, + dest, + name, + path, + parentEntry + ); + + injection.lazyInject(); + } +} + +/** + * The methods in this singleton represent the "format" specifier for + * JSON Schema string types. + * + * Each method either returns a normalized version of the original + * value, or throws an error if the value is not valid for the given + * format. + */ +const FORMATS = { + hostname(string, context) { + // TODO bug 1797376: Despite the name, this format is NOT a "hostname", + // but hostname + port and may fail with IPv6. Use canonicalDomain instead. + let valid = true; + + try { + valid = new URL(`http://${string}`).host === string; + } catch (e) { + valid = false; + } + + if (!valid) { + throw new Error(`Invalid hostname ${string}`); + } + + return string; + }, + + canonicalDomain(string, context) { + let valid; + + try { + valid = new URL(`http://${string}`).hostname === string; + } catch (e) { + valid = false; + } + + if (!valid) { + // Require the input to be a canonical domain. + // Rejects obvious non-domains such as URLs, + // but also catches non-IDN (punycode) domains. + throw new Error(`Invalid domain ${string}`); + } + + return string; + }, + + url(string, context) { + let url = new URL(string).href; + + if (!context.checkLoadURL(url)) { + throw new Error(`Access denied for URL ${url}`); + } + return url; + }, + + origin(string, context) { + let url; + try { + url = new URL(string); + } catch (e) { + throw new Error(`Invalid origin: ${string}`); + } + if (!/^https?:/.test(url.protocol)) { + throw new Error(`Invalid origin must be http or https for URL ${string}`); + } + // url.origin is punycode so a direct check against string wont work. + // url.href appends a slash even if not in the original string, we we + // additionally check that string does not end in slash. + if (string.endsWith("/") || url.href != new URL(url.origin).href) { + throw new Error( + `Invalid origin for URL ${string}, replace with origin ${url.origin}` + ); + } + if (!context.checkLoadURL(url.origin)) { + throw new Error(`Access denied for URL ${url}`); + } + return url.origin; + }, + + relativeUrl(string, context) { + if (!context.url) { + // If there's no context URL, return relative URLs unresolved, and + // skip security checks for them. + try { + new URL(string); + } catch (e) { + return string; + } + } + + let url = new URL(string, context.url).href; + + if (!context.checkLoadURL(url)) { + throw new Error(`Access denied for URL ${url}`); + } + return url; + }, + + strictRelativeUrl(string, context) { + void FORMATS.unresolvedRelativeUrl(string, context); + return FORMATS.relativeUrl(string, context); + }, + + unresolvedRelativeUrl(string, context) { + if (!string.startsWith("//")) { + try { + new URL(string); + } catch (e) { + return string; + } + } + + throw new SyntaxError( + `String ${JSON.stringify(string)} must be a relative URL` + ); + }, + + homepageUrl(string, context) { + // Pipes are used for separating homepages, but we only allow extensions to + // set a single homepage. Encoding any pipes makes it one URL. + return FORMATS.relativeUrl( + string.replace(new RegExp("\\|", "g"), "%7C"), + context + ); + }, + + imageDataOrStrictRelativeUrl(string, context) { + // Do not accept a string which resolves as an absolute URL, or any + // protocol-relative URL, except PNG or JPG data URLs + if ( + !string.startsWith("data:image/png;base64,") && + !string.startsWith("data:image/jpeg;base64,") + ) { + try { + return FORMATS.strictRelativeUrl(string, context); + } catch (e) { + throw new SyntaxError( + `String ${JSON.stringify( + string + )} must be a relative or PNG or JPG data:image URL` + ); + } + } + return string; + }, + + contentSecurityPolicy(string, context) { + // Manifest V3 extension_pages allows WASM. When sandbox is + // implemented, or any other V3 or later directive, the flags + // logic will need to be updated. + let flags = + context.manifestVersion < 3 + ? Ci.nsIAddonContentPolicy.CSP_ALLOW_ANY + : Ci.nsIAddonContentPolicy.CSP_ALLOW_WASM; + let error = lazy.contentPolicyService.validateAddonCSP(string, flags); + if (error != null) { + // The CSP validation error is not reported as part of the "choices" error message, + // we log the CSP validation error explicitly here to make it easier for the addon developers + // to see and fix the extension CSP. + context.logError(`Error processing ${context.currentTarget}: ${error}`); + return null; + } + return string; + }, + + date(string, context) { + // A valid ISO 8601 timestamp. + const PATTERN = + /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{3})?(Z|([-+]\d{2}:?\d{2})))?$/; + if (!PATTERN.test(string)) { + throw new Error(`Invalid date string ${string}`); + } + // Our pattern just checks the format, we could still have invalid + // values (e.g., month=99 or month=02 and day=31). Let the Date + // constructor do the dirty work of validating. + if (isNaN(Date.parse(string))) { + throw new Error(`Invalid date string ${string}`); + } + return string; + }, + + manifestShortcutKey(string, context) { + if (lazy.ShortcutUtils.validate(string) == lazy.ShortcutUtils.IS_VALID) { + return string; + } + let errorMessage = + `Value "${string}" must consist of ` + + `either a combination of one or two modifiers, including ` + + `a mandatory primary modifier and a key, separated by '+', ` + + `or a media key. For details see: ` + + `https://developer.mozilla.org/en-US/Add-ons/WebExtensions/manifest.json/commands#Key_combinations`; + throw new Error(errorMessage); + }, + + manifestShortcutKeyOrEmpty(string, context) { + return string === "" ? "" : FORMATS.manifestShortcutKey(string, context); + }, + + versionString(string, context) { + const parts = string.split("."); + + if ( + // We accept up to 4 numbers. + parts.length > 4 || + // Non-zero values cannot start with 0 and we allow numbers up to 9 digits. + parts.some(part => !/^(0|[1-9][0-9]{0,8})$/.test(part)) + ) { + context.logWarning( + `version must be a version string consisting of at most 4 integers ` + + `of at most 9 digits without leading zeros, and separated with dots` + ); + } + + // The idea is to only emit a warning when the version string does not + // match the simple format we want to encourage developers to use. Given + // the version is required, we always accept the value as is. + return string; + }, +}; + +// Schema files contain namespaces, and each namespace contains types, +// properties, functions, and events. An Entry is a base class for +// types, properties, functions, and events. +class Entry { + constructor(schema = {}) { + /** + * If set to any value which evaluates as true, this entry is + * deprecated, and any access to it will result in a deprecation + * warning being logged to the browser console. + * + * If the value is a string, it will be appended to the deprecation + * message. If it contains the substring "${value}", it will be + * replaced with a string representation of the value being + * processed. + * + * If the value is any other truthy value, a generic deprecation + * message will be emitted. + */ + this.deprecated = false; + if ("deprecated" in schema) { + this.deprecated = schema.deprecated; + } + + /** + * @property {string} [preprocessor] + * If set to a string value, and a preprocessor of the same is + * defined in the validation context, it will be applied to this + * value prior to any normalization. + */ + this.preprocessor = schema.preprocess || null; + + /** + * @property {string} [postprocessor] + * If set to a string value, and a postprocessor of the same is + * defined in the validation context, it will be applied to this + * value after any normalization. + */ + this.postprocessor = schema.postprocess || null; + + /** + * @property {Array} allowedContexts A list of allowed contexts + * to consider before generating the API. + * These are not parsed by the schema, but passed to `shouldInject`. + */ + this.allowedContexts = schema.allowedContexts || []; + + this.min_manifest_version = + schema.min_manifest_version ?? MIN_MANIFEST_VERSION; + this.max_manifest_version = + schema.max_manifest_version ?? MAX_MANIFEST_VERSION; + } + + /** + * Preprocess the given value with the preprocessor declared in + * `preprocessor`. + * + * @param {*} value + * @param {Context} context + * @returns {*} + */ + preprocess(value, context) { + if (this.preprocessor) { + return context.preprocessors[this.preprocessor](value, context); + } + return value; + } + + /** + * Postprocess the given result with the postprocessor declared in + * `postprocessor`. + * + * @param {object} result + * @param {Context} context + * @returns {object} + */ + postprocess(result, context) { + if (result.error || !this.postprocessor) { + return result; + } + + let value = context.postprocessors[this.postprocessor]( + result.value, + context + ); + return { value }; + } + + /** + * Logs a deprecation warning for this entry, based on the value of + * its `deprecated` property. + * + * @param {Context} context + * @param {any} [value] + */ + logDeprecation(context, value = null) { + let message = "This property is deprecated"; + if (typeof this.deprecated == "string") { + message = this.deprecated; + if (message.includes("${value}")) { + try { + value = JSON.stringify(value); + } catch (e) { + value = String(value); + } + message = message.replace(/\$\{value\}/g, () => value); + } + } + + context.logWarning(message); + } + + /** + * Checks whether the entry is deprecated and, if so, logs a + * deprecation message. + * + * @param {Context} context + * @param {any} [value] + */ + checkDeprecated(context, value = null) { + if (this.deprecated) { + this.logDeprecation(context, value); + } + } + + /** + * Returns an object containing property descriptor for use when + * injecting this entry into an API object. + * + * @param {Array} path The API path, e.g. `["storage", "local"]`. + * @param {InjectionContext} context + * + * @returns {object?} + * An object containing a `descriptor` property, specifying the + * entry's property descriptor, and an optional `revoke` + * method, to be called when the entry is being revoked. + */ + getDescriptor(path, context) { + return undefined; + } +} + +// Corresponds either to a type declared in the "types" section of the +// schema or else to any type object used throughout the schema. +class Type extends Entry { + /** + * @property {Array} EXTRA_PROPERTIES + * An array of extra properties which may be present for + * schemas of this type. + */ + static get EXTRA_PROPERTIES() { + return [ + "description", + "deprecated", + "preprocess", + "postprocess", + "privileged", + "allowedContexts", + "min_manifest_version", + "max_manifest_version", + ]; + } + + /** + * Parses the given schema object and returns an instance of this + * class which corresponds to its properties. + * + * @param {SchemaRoot} root + * The root schema for this type. + * @param {object} schema + * A JSON schema object which corresponds to a definition of + * this type. + * @param {Array} path + * The path to this schema object from the root schema, + * corresponding to the property names and array indices + * traversed during parsing in order to arrive at this schema + * object. + * @param {Array} [extraProperties] + * An array of extra property names which are valid for this + * schema in the current context. + * @returns {Type} + * An instance of this type which corresponds to the given + * schema object. + * @static + */ + static parseSchema(root, schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + return new this(schema); + } + + /** + * Checks that all of the properties present in the given schema + * object are valid properties for this type, and throws if invalid. + * + * @param {object} schema + * A JSON schema object. + * @param {Array} path + * The path to this schema object from the root schema, + * corresponding to the property names and array indices + * traversed during parsing in order to arrive at this schema + * object. + * @param {Iterable} [extra] + * An array of extra property names which are valid for this + * schema in the current context. + * @throws {Error} + * An error describing the first invalid property found in the + * schema object. + */ + static checkSchemaProperties(schema, path, extra = []) { + if (DEBUG) { + let allowedSet = new Set([...this.EXTRA_PROPERTIES, ...extra]); + + for (let prop of Object.keys(schema)) { + if (!allowedSet.has(prop)) { + throw new Error( + `Internal error: Namespace ${path.join(".")} has ` + + `invalid type property "${prop}" ` + + `in type "${schema.id || JSON.stringify(schema)}"` + ); + } + } + } + } + + // Takes a value, checks that it has the correct type, and returns a + // "normalized" version of the value. The normalized version will + // include "nulls" in place of omitted optional properties. The + // result of this function is either {error: "Some type error"} or + // {value: }. + normalize(value, context) { + return context.error("invalid type"); + } + + // Unlike normalize, this function does a shallow check to see if + // |baseType| (one of the possible getValueBaseType results) is + // valid for this type. It returns true or false. It's used to fill + // in optional arguments to functions before actually type checking + + checkBaseType(baseType) { + return false; + } + + // Helper method that simply relies on checkBaseType to implement + // normalize. Subclasses can choose to use it or not. + normalizeBase(type, value, context) { + if (this.checkBaseType(getValueBaseType(value))) { + this.checkDeprecated(context, value); + return { value: this.preprocess(value, context) }; + } + + let choice; + if ("aeiou".includes(type[0])) { + choice = `be an ${type} value`; + } else { + choice = `be a ${type} value`; + } + + return context.error( + () => `Expected ${type} instead of ${JSON.stringify(value)}`, + choice + ); + } +} + +// Type that allows any value. +class AnyType extends Type { + normalize(value, context) { + this.checkDeprecated(context, value); + return this.postprocess({ value }, context); + } + + checkBaseType(baseType) { + return true; + } +} + +// An untagged union type. +class ChoiceType extends Type { + static get EXTRA_PROPERTIES() { + return ["choices", ...super.EXTRA_PROPERTIES]; + } + + /** @type {(root, schema, path, extraProperties?: Iterable) => ChoiceType} */ + static parseSchema(root, schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + let choices = schema.choices.map(t => root.parseSchema(t, path)); + return new this(schema, choices); + } + + constructor(schema, choices) { + super(schema); + this.choices = choices; + } + + extend(type) { + this.choices.push(...type.choices); + + return this; + } + + normalize(value, context) { + this.checkDeprecated(context, value); + + let error; + let { choices, result } = context.withChoices(() => { + for (let choice of this.choices) { + // Ignore a possible choice if it is not supported by + // the manifest version we are normalizing. + if (!context.matchManifestVersion(choice)) { + continue; + } + + let r = choice.normalize(value, context); + if (!r.error) { + return r; + } + + error = r; + } + }); + + if (result) { + return result; + } + if (choices.size <= 1) { + return error; + } + + choices = Array.from(choices, forceString); + let n = choices.length - 1; + choices[n] = `or ${choices[n]}`; + + let message; + if (typeof value === "object") { + message = () => `Value must either: ${choices.join(", ")}`; + } else { + message = () => + `Value ${JSON.stringify(value)} must either: ${choices.join(", ")}`; + } + + return context.error(message, null); + } + + checkBaseType(baseType) { + return this.choices.some(t => t.checkBaseType(baseType)); + } + + getDescriptor(path, context) { + // In StringType.getDescriptor, unlike any other Type, a descriptor is returned if + // it is an enumeration. Since we need versioned choices in some cases, here we + // build a list of valid enumerations that will work for a given manifest version. + if ( + !this.choices.length || + !this.choices.every(t => t.checkBaseType("string") && t.enumeration) + ) { + return; + } + + let obj = Cu.createObjectIn(context.cloneScope); + let descriptor = { value: obj }; + for (let choice of this.choices) { + // Ignore a possible choice if it is not supported by + // the manifest version we are normalizing. + if (!context.matchManifestVersion(choice)) { + continue; + } + let d = choice.getDescriptor(path, context); + if (d) { + Object.assign(obj, d.descriptor.value); + } + } + + return { descriptor }; + } +} + +// This is a reference to another type--essentially a typedef. +class RefType extends Type { + static get EXTRA_PROPERTIES() { + return ["$ref", ...super.EXTRA_PROPERTIES]; + } + + /** @type {(root, schema, path, extraProperties?: Iterable) => RefType} */ + static parseSchema(root, schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + let ref = schema.$ref; + let ns = path.join("."); + if (ref.includes(".")) { + [, ns, ref] = /^(.*)\.(.*?)$/.exec(ref); + } + return new this(root, schema, ns, ref); + } + + // For a reference to a type named T declared in namespace NS, + // namespaceName will be NS and reference will be T. + constructor(root, schema, namespaceName, reference) { + super(schema); + this.root = root; + this.namespaceName = namespaceName; + this.reference = reference; + } + + get targetType() { + let ns = this.root.getNamespace(this.namespaceName); + let type = ns.get(this.reference); + if (!type) { + throw new Error(`Internal error: Type ${this.reference} not found`); + } + return type; + } + + normalize(value, context) { + this.checkDeprecated(context, value); + return this.targetType.normalize(value, context); + } + + checkBaseType(baseType) { + return this.targetType.checkBaseType(baseType); + } +} + +class StringType extends Type { + static get EXTRA_PROPERTIES() { + return [ + "enum", + "minLength", + "maxLength", + "pattern", + "format", + ...super.EXTRA_PROPERTIES, + ]; + } + + static parseSchema(root, schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + let enumeration = schema.enum || null; + if (enumeration) { + // The "enum" property is either a list of strings that are + // valid values or else a list of {name, description} objects, + // where the .name values are the valid values. + enumeration = enumeration.map(e => { + if (typeof e == "object") { + return e.name; + } + return e; + }); + } + + let pattern = null; + if (schema.pattern) { + try { + pattern = parsePattern(schema.pattern); + } catch (e) { + throw new Error( + `Internal error: Invalid pattern ${JSON.stringify(schema.pattern)}` + ); + } + } + + let format = null; + if (schema.format) { + if (!(schema.format in FORMATS)) { + throw new Error( + `Internal error: Invalid string format ${schema.format}` + ); + } + format = FORMATS[schema.format]; + } + return new this( + schema, + schema.id || undefined, + enumeration, + schema.minLength || 0, + schema.maxLength || Infinity, + pattern, + format + ); + } + + constructor( + schema, + name, + enumeration, + minLength, + maxLength, + pattern, + format + ) { + super(schema); + this.name = name; + this.enumeration = enumeration; + this.minLength = minLength; + this.maxLength = maxLength; + this.pattern = pattern; + this.format = format; + } + + normalize(value, context) { + let r = this.normalizeBase("string", value, context); + if (r.error) { + return r; + } + value = r.value; + + if (this.enumeration) { + if (this.enumeration.includes(value)) { + return this.postprocess({ value }, context); + } + + let choices = this.enumeration.map(JSON.stringify).join(", "); + + return context.error( + () => `Invalid enumeration value ${JSON.stringify(value)}`, + `be one of [${choices}]` + ); + } + + if (value.length < this.minLength) { + return context.error( + () => + `String ${JSON.stringify(value)} is too short (must be ${ + this.minLength + })`, + `be longer than ${this.minLength}` + ); + } + if (value.length > this.maxLength) { + return context.error( + () => + `String ${JSON.stringify(value)} is too long (must be ${ + this.maxLength + })`, + `be shorter than ${this.maxLength}` + ); + } + + if (this.pattern && !this.pattern.test(value)) { + return context.error( + () => `String ${JSON.stringify(value)} must match ${this.pattern}`, + `match the pattern ${this.pattern.toSource()}` + ); + } + + if (this.format) { + try { + r.value = this.format(r.value, context); + } catch (e) { + return context.error( + String(e), + `match the format "${this.format.name}"` + ); + } + } + + return r; + } + + checkBaseType(baseType) { + return baseType == "string"; + } + + getDescriptor(path, context) { + if (this.enumeration) { + let obj = Cu.createObjectIn(context.cloneScope); + + for (let e of this.enumeration) { + obj[e.toUpperCase()] = e; + } + + return { + descriptor: { value: obj }, + }; + } + } +} + +class NullType extends Type { + normalize(value, context) { + return this.normalizeBase("null", value, context); + } + + checkBaseType(baseType) { + return baseType == "null"; + } +} + +let FunctionEntry; +let Event; +let SubModuleType; + +class ObjectType extends Type { + static get EXTRA_PROPERTIES() { + return [ + "properties", + "patternProperties", + "$import", + ...super.EXTRA_PROPERTIES, + ]; + } + + static parseSchema(root, schema, path, extraProperties = []) { + if ("functions" in schema) { + return SubModuleType.parseSchema(root, schema, path, extraProperties); + } + + if (DEBUG && !("$extend" in schema)) { + // Only allow extending "properties" and "patternProperties". + extraProperties = [ + "additionalProperties", + "isInstanceOf", + ...extraProperties, + ]; + } + this.checkSchemaProperties(schema, path, extraProperties); + + let imported = null; + if ("$import" in schema) { + let importPath = schema.$import; + let idx = importPath.indexOf("."); + if (idx === -1) { + imported = [path[0], importPath]; + } else { + imported = [importPath.slice(0, idx), importPath.slice(idx + 1)]; + } + } + + let parseProperty = (schema, extraProps = []) => { + return { + type: root.parseSchema( + schema, + path, + DEBUG && [ + "unsupported", + "onError", + "permissions", + "default", + ...extraProps, + ] + ), + optional: schema.optional || false, + unsupported: schema.unsupported || false, + onError: schema.onError || null, + default: schema.default === undefined ? null : schema.default, + }; + }; + + // Parse explicit "properties" object. + let properties = Object.create(null); + for (let propName of Object.keys(schema.properties || {})) { + properties[propName] = parseProperty(schema.properties[propName], [ + "optional", + ]); + } + + // Parse regexp properties from "patternProperties" object. + let patternProperties = []; + for (let propName of Object.keys(schema.patternProperties || {})) { + let pattern; + try { + pattern = parsePattern(propName); + } catch (e) { + throw new Error( + `Internal error: Invalid property pattern ${JSON.stringify(propName)}` + ); + } + + patternProperties.push({ + pattern, + type: parseProperty(schema.patternProperties[propName]), + }); + } + + // Parse "additionalProperties" schema. + let additionalProperties = null; + if (schema.additionalProperties) { + let type = schema.additionalProperties; + if (type === true) { + type = { type: "any" }; + } + + additionalProperties = root.parseSchema(type, path); + } + + return new this( + schema, + properties, + additionalProperties, + patternProperties, + schema.isInstanceOf || null, + imported + ); + } + + constructor( + schema, + properties, + additionalProperties, + patternProperties, + isInstanceOf, + imported + ) { + super(schema); + this.properties = properties; + this.additionalProperties = additionalProperties; + this.patternProperties = patternProperties; + this.isInstanceOf = isInstanceOf; + + if (imported) { + let [ns, path] = imported; + ns = Schemas.getNamespace(ns); + let importedType = ns.get(path); + if (!importedType) { + throw new Error(`Internal error: imported type ${path} not found`); + } + + if (DEBUG && !(importedType instanceof ObjectType)) { + throw new Error( + `Internal error: cannot import non-object type ${path}` + ); + } + + this.properties = Object.assign( + {}, + importedType.properties, + this.properties + ); + this.patternProperties = [ + ...importedType.patternProperties, + ...this.patternProperties, + ]; + this.additionalProperties = + importedType.additionalProperties || this.additionalProperties; + } + } + + extend(type) { + for (let key of Object.keys(type.properties)) { + if (key in this.properties) { + throw new Error( + `InternalError: Attempt to extend an object with conflicting property "${key}"` + ); + } + this.properties[key] = type.properties[key]; + } + + this.patternProperties.push(...type.patternProperties); + + return this; + } + + checkBaseType(baseType) { + return baseType == "object"; + } + + /** + * Extracts the enumerable properties of the given object, including + * function properties which would normally be omitted by X-ray + * wrappers. + * + * @param {object} value + * @param {Context} context + * The current parse context. + * @returns {object} + * An object with an `error` or `value` property. + */ + extractProperties(value, context) { + // |value| should be a JS Xray wrapping an object in the + // extension compartment. This works well except when we need to + // access callable properties on |value| since JS Xrays don't + // support those. To work around the problem, we verify that + // |value| is a plain JS object (i.e., not anything scary like a + // Proxy). Then we copy the properties out of it into a normal + // object using a waiver wrapper. + + let klass = ChromeUtils.getClassName(value, true); + if (klass != "Object") { + throw context.error( + `Expected a plain JavaScript object, got a ${klass}`, + `be a plain JavaScript object` + ); + } + + return ChromeUtils.shallowClone(value); + } + + checkProperty(context, prop, propType, result, properties, remainingProps) { + let { type, optional, unsupported, onError } = propType; + let error = null; + + if (!context.matchManifestVersion(type)) { + if (prop in properties) { + error = context.error( + `Property "${prop}" is unsupported in Manifest Version ${context.manifestVersion}`, + `not contain an unsupported "${prop}" property` + ); + + context.logWarning(forceString(error.error)); + if (this.additionalProperties) { + // When `additionalProperties` is set to UnrecognizedProperty, the + // caller (i.e. ObjectType's normalize method) assigns the original + // value to `result[prop]`. Erase the property now to prevent + // `result[prop]` from becoming anything other than `undefined. + // + // A warning was already logged above, so we do not need to also log + // "An unexpected property was found in the WebExtension manifest." + remainingProps.delete(prop); + } + // When `additionalProperties` is not set, ObjectType's normalize method + // will return an error because prop is still in remainingProps. + return; + } + } else if (unsupported) { + if (prop in properties) { + error = context.error( + `Property "${prop}" is unsupported by Firefox`, + `not contain an unsupported "${prop}" property` + ); + } + } else if (prop in properties) { + if ( + optional && + (properties[prop] === null || properties[prop] === undefined) + ) { + result[prop] = propType.default; + } else { + let r = context.withPath(prop, () => + type.normalize(properties[prop], context) + ); + if (r.error) { + error = r; + } else { + result[prop] = r.value; + properties[prop] = r.value; + } + } + remainingProps.delete(prop); + } else if (!optional) { + error = context.error( + `Property "${prop}" is required`, + `contain the required "${prop}" property` + ); + } else if (optional !== "omit-key-if-missing") { + result[prop] = propType.default; + } + + if (error) { + if (onError == "warn") { + context.logWarning(forceString(error.error)); + } else if (onError != "ignore") { + throw error; + } + + result[prop] = propType.default; + } + } + + normalize(value, context) { + try { + let v = this.normalizeBase("object", value, context); + if (v.error) { + return v; + } + value = v.value; + + if (this.isInstanceOf) { + if (DEBUG) { + if ( + Object.keys(this.properties).length || + this.patternProperties.length || + !(this.additionalProperties instanceof AnyType) + ) { + throw new Error( + "InternalError: isInstanceOf can only be used " + + "with objects that are otherwise unrestricted" + ); + } + } + + if ( + ChromeUtils.getClassName(value) !== this.isInstanceOf && + (this.isInstanceOf !== "Element" || value.nodeType !== 1) + ) { + return context.error( + `Object must be an instance of ${this.isInstanceOf}`, + `be an instance of ${this.isInstanceOf}` + ); + } + + // This is kind of a hack, but we can't normalize things that + // aren't JSON, so we just return them. + return this.postprocess({ value }, context); + } + + let properties = this.extractProperties(value, context); + let remainingProps = new Set(Object.keys(properties)); + + let result = {}; + for (let prop of Object.keys(this.properties)) { + this.checkProperty( + context, + prop, + this.properties[prop], + result, + properties, + remainingProps + ); + } + + for (let prop of Object.keys(properties)) { + for (let { pattern, type } of this.patternProperties) { + if (pattern.test(prop)) { + this.checkProperty( + context, + prop, + type, + result, + properties, + remainingProps + ); + } + } + } + + if (this.additionalProperties) { + for (let prop of remainingProps) { + let r = context.withPath(prop, () => + this.additionalProperties.normalize(properties[prop], context) + ); + if (r.error) { + return r; + } + result[prop] = r.value; + } + } else if (remainingProps.size == 1) { + return context.error( + `Unexpected property "${[...remainingProps]}"`, + `not contain an unexpected "${[...remainingProps]}" property` + ); + } else if (remainingProps.size) { + let props = [...remainingProps].sort().join(", "); + return context.error( + `Unexpected properties: ${props}`, + `not contain the unexpected properties [${props}]` + ); + } + + return this.postprocess({ value: result }, context); + } catch (e) { + if (e.error) { + return e; + } + throw e; + } + } +} + +// This type is just a placeholder to be referred to by +// SubModuleProperty. No value is ever expected to have this type. +SubModuleType = class SubModuleType extends Type { + static get EXTRA_PROPERTIES() { + return ["functions", "events", "properties", ...super.EXTRA_PROPERTIES]; + } + + static parseSchema(root, schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + // The path we pass in here is only used for error messages. + path = [...path, schema.id]; + let functions = schema.functions + .filter(fun => !fun.unsupported) + .map(fun => FunctionEntry.parseSchema(root, fun, path)); + + let events = []; + + if (schema.events) { + events = schema.events + .filter(event => !event.unsupported) + .map(event => Event.parseSchema(root, event, path)); + } + + return new this(schema, functions, events); + } + + constructor(schema, functions, events) { + // schema contains properties such as min/max_manifest_version needed + // in the base class so that the Context class can version compare + // any entries against the manifest version. + super(schema); + this.functions = functions; + this.events = events; + } +}; + +class NumberType extends Type { + normalize(value, context) { + let r = this.normalizeBase("number", value, context); + if (r.error) { + return r; + } + + if (isNaN(r.value) || !Number.isFinite(r.value)) { + return context.error( + "NaN and infinity are not valid", + "be a finite number" + ); + } + + return r; + } + + checkBaseType(baseType) { + return baseType == "number" || baseType == "integer"; + } +} + +class IntegerType extends Type { + static get EXTRA_PROPERTIES() { + return ["minimum", "maximum", ...super.EXTRA_PROPERTIES]; + } + + static parseSchema(root, schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + let { minimum = -Infinity, maximum = Infinity } = schema; + return new this(schema, minimum, maximum); + } + + constructor(schema, minimum, maximum) { + super(schema); + this.minimum = minimum; + this.maximum = maximum; + } + + normalize(value, context) { + let r = this.normalizeBase("integer", value, context); + if (r.error) { + return r; + } + value = r.value; + + // Ensure it's between -2**31 and 2**31-1 + if (!Number.isSafeInteger(value)) { + return context.error( + "Integer is out of range", + "be a valid 32 bit signed integer" + ); + } + + if (value < this.minimum) { + return context.error( + `Integer ${value} is too small (must be at least ${this.minimum})`, + `be at least ${this.minimum}` + ); + } + if (value > this.maximum) { + return context.error( + `Integer ${value} is too big (must be at most ${this.maximum})`, + `be no greater than ${this.maximum}` + ); + } + + return this.postprocess(r, context); + } + + checkBaseType(baseType) { + return baseType == "integer"; + } +} + +class BooleanType extends Type { + static get EXTRA_PROPERTIES() { + return ["enum", ...super.EXTRA_PROPERTIES]; + } + + static parseSchema(root, schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + let enumeration = schema.enum || null; + return new this(schema, enumeration); + } + + constructor(schema, enumeration) { + super(schema); + this.enumeration = enumeration; + } + + normalize(value, context) { + if (!this.checkBaseType(getValueBaseType(value))) { + return context.error( + () => `Expected boolean instead of ${JSON.stringify(value)}`, + `be a boolean` + ); + } + value = this.preprocess(value, context); + if (this.enumeration && !this.enumeration.includes(value)) { + return context.error( + () => `Invalid value ${JSON.stringify(value)}`, + `be ${this.enumeration}` + ); + } + this.checkDeprecated(context, value); + return { value }; + } + + checkBaseType(baseType) { + return baseType == "boolean"; + } +} + +class ArrayType extends Type { + static get EXTRA_PROPERTIES() { + return ["items", "minItems", "maxItems", ...super.EXTRA_PROPERTIES]; + } + + static parseSchema(root, schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + let items = root.parseSchema(schema.items, path, ["onError"]); + + return new this( + schema, + items, + schema.minItems || 0, + schema.maxItems || Infinity + ); + } + + constructor(schema, itemType, minItems, maxItems) { + super(schema); + this.itemType = itemType; + this.minItems = minItems; + this.maxItems = maxItems; + this.onError = schema.items.onError || null; + } + + normalize(value, context) { + let v = this.normalizeBase("array", value, context); + if (v.error) { + return v; + } + value = v.value; + + let result = []; + for (let [i, element] of value.entries()) { + element = context.withPath(String(i), () => + this.itemType.normalize(element, context) + ); + if (element.error) { + if (this.onError == "warn") { + context.logWarning(forceString(element.error)); + } else if (this.onError != "ignore") { + return element; + } + continue; + } + result.push(element.value); + } + + if (result.length < this.minItems) { + return context.error( + `Array requires at least ${this.minItems} items; you have ${result.length}`, + `have at least ${this.minItems} items` + ); + } + + if (result.length > this.maxItems) { + return context.error( + `Array requires at most ${this.maxItems} items; you have ${result.length}`, + `have at most ${this.maxItems} items` + ); + } + + return this.postprocess({ value: result }, context); + } + + checkBaseType(baseType) { + return baseType == "array"; + } +} + +class FunctionType extends Type { + static get EXTRA_PROPERTIES() { + return [ + "parameters", + "async", + "returns", + "requireUserInput", + ...super.EXTRA_PROPERTIES, + ]; + } + + static parseSchema(root, schema, path, extraProperties = []) { + this.checkSchemaProperties(schema, path, extraProperties); + + let isAsync = !!schema.async; + let isExpectingCallback = typeof schema.async === "string"; + let parameters = null; + if ("parameters" in schema) { + parameters = []; + for (let param of schema.parameters) { + // Callbacks default to optional for now, because of promise + // handling. + let isCallback = isAsync && param.name == schema.async; + if (isCallback) { + isExpectingCallback = false; + } + + parameters.push({ + type: root.parseSchema(param, path, ["name", "optional", "default"]), + name: param.name, + optional: param.optional == null ? isCallback : param.optional, + default: param.default == undefined ? null : param.default, + }); + } + } + let hasAsyncCallback = false; + if (isAsync) { + hasAsyncCallback = + parameters && + parameters.length && + parameters[parameters.length - 1].name == schema.async; + } + + if (DEBUG) { + if (isExpectingCallback) { + throw new Error( + `Internal error: Expected a callback parameter ` + + `with name ${schema.async}` + ); + } + + if (isAsync && schema.returns) { + throw new Error( + "Internal error: Async functions must not have return values." + ); + } + if ( + isAsync && + schema.allowAmbiguousOptionalArguments && + !hasAsyncCallback + ) { + throw new Error( + "Internal error: Async functions with ambiguous " + + "arguments must declare the callback as the last parameter" + ); + } + } + + return new this( + schema, + parameters, + isAsync, + hasAsyncCallback, + !!schema.requireUserInput + ); + } + + constructor(schema, parameters, isAsync, hasAsyncCallback, requireUserInput) { + super(schema); + this.parameters = parameters; + this.isAsync = isAsync; + this.hasAsyncCallback = hasAsyncCallback; + this.requireUserInput = requireUserInput; + } + + normalize(value, context) { + return this.normalizeBase("function", value, context); + } + + checkBaseType(baseType) { + return baseType == "function"; + } +} + +// Represents a "property" defined in a schema namespace with a +// particular value. Essentially this is a constant. +class ValueProperty extends Entry { + constructor(schema, name, value) { + super(schema); + this.name = name; + this.value = value; + } + + getDescriptor(path, context) { + // Prevent injection if not a supported version. + if (!context.matchManifestVersion(this)) { + return; + } + + return { + descriptor: { value: this.value }, + }; + } +} + +// Represents a "property" defined in a schema namespace that is not a +// constant. +class TypeProperty extends Entry { + unsupported = false; + + constructor(schema, path, name, type, writable, permissions) { + super(schema); + this.path = path; + this.name = name; + this.type = type; + this.writable = writable; + this.permissions = permissions; + } + + throwError(context, msg) { + throw context.makeError(`${msg} for ${this.path.join(".")}.${this.name}.`); + } + + getDescriptor(path, context) { + if (this.unsupported || !context.matchManifestVersion(this)) { + return; + } + + let apiImpl = context.getImplementation(path.join("."), this.name); + + let getStub = () => { + this.checkDeprecated(context); + return apiImpl.getProperty(); + }; + + let descriptor = { + get: Cu.exportFunction(getStub, context.cloneScope), + }; + + if (this.writable) { + let setStub = value => { + let normalized = this.type.normalize(value, context); + if (normalized.error) { + this.throwError(context, forceString(normalized.error)); + } + + apiImpl.setProperty(normalized.value); + }; + + descriptor.set = Cu.exportFunction(setStub, context.cloneScope); + } + + return { + descriptor, + revoke() { + apiImpl.revoke(); + apiImpl = null; + }, + }; + } +} + +class SubModuleProperty extends Entry { + // A SubModuleProperty represents a tree of objects and properties + // to expose to an extension. Currently we support only a limited + // form of sub-module properties, where "$ref" points to a + // SubModuleType containing a list of functions and "properties" is + // a list of additional simple properties. + // + // name: Name of the property stuff is being added to. + // namespaceName: Namespace in which the property lives. + // reference: Name of the type defining the functions to add to the property. + // properties: Additional properties to add to the module (unsupported). + constructor(root, schema, path, name, reference, properties, permissions) { + super(schema); + this.root = root; + this.name = name; + this.path = path; + this.namespaceName = path.join("."); + this.reference = reference; + this.properties = properties; + this.permissions = permissions; + } + + get targetType() { + let ns = this.root.getNamespace(this.namespaceName); + let type = ns.get(this.reference); + if (!type && this.reference.includes(".")) { + let [namespaceName, ref] = this.reference.split("."); + ns = this.root.getNamespace(namespaceName); + type = ns.get(ref); + } + return type; + } + + getDescriptor(path, context) { + let obj = Cu.createObjectIn(context.cloneScope); + + let ns = this.root.getNamespace(this.namespaceName); + let type = this.targetType; + + // Prevent injection if not a supported version. + if (!context.matchManifestVersion(type)) { + return; + } + + if (DEBUG) { + if (!type || !(type instanceof SubModuleType)) { + throw new Error( + `Internal error: ${this.namespaceName}.${this.reference} ` + + `is not a sub-module` + ); + } + } + let subpath = [...path, this.name]; + + let functions = type.functions; + for (let fun of functions) { + context.injectInto(fun, obj, fun.name, subpath, ns); + } + + let events = type.events; + for (let event of events) { + context.injectInto(event, obj, event.name, subpath, ns); + } + + // TODO: Inject this.properties. + + return { + descriptor: { value: obj }, + revoke() { + let unwrapped = ChromeUtils.waiveXrays(obj); + for (let fun of functions) { + try { + delete unwrapped[fun.name]; + } catch (e) { + Cu.reportError(e); + } + } + }, + }; + } +} + +// This class is a base class for FunctionEntrys and Events. It takes +// care of validating parameter lists (i.e., handling of optional +// parameters and parameter type checking). +class CallEntry extends Entry { + hasAsyncCallback = false; + + constructor(schema, path, name, parameters, allowAmbiguousOptionalArguments) { + super(schema); + this.path = path; + this.name = name; + this.parameters = parameters; + this.allowAmbiguousOptionalArguments = allowAmbiguousOptionalArguments; + } + + throwError(context, msg) { + throw context.makeError(`${msg} for ${this.path.join(".")}.${this.name}.`); + } + + checkParameters(args, context) { + let fixedArgs = []; + + // First we create a new array, fixedArgs, that is the same as + // |args| but with default values in place of omitted optional parameters. + let check = (parameterIndex, argIndex) => { + if (parameterIndex == this.parameters.length) { + if (argIndex == args.length) { + return true; + } + return false; + } + + let parameter = this.parameters[parameterIndex]; + if (parameter.optional) { + // Try skipping it. + fixedArgs[parameterIndex] = parameter.default; + if (check(parameterIndex + 1, argIndex)) { + return true; + } + } + + if (argIndex == args.length) { + return false; + } + + let arg = args[argIndex]; + if (!parameter.type.checkBaseType(getValueBaseType(arg))) { + // For Chrome compatibility, use the default value if null or undefined + // is explicitly passed but is not a valid argument in this position. + if (parameter.optional && (arg === null || arg === undefined)) { + fixedArgs[parameterIndex] = Cu.cloneInto(parameter.default, {}); + } else { + return false; + } + } else { + fixedArgs[parameterIndex] = arg; + } + + return check(parameterIndex + 1, argIndex + 1); + }; + + if (this.allowAmbiguousOptionalArguments) { + // When this option is set, it's up to the implementation to + // parse arguments. + // The last argument for asynchronous methods is either a function or null. + // This is specifically done for runtime.sendMessage. + if (this.hasAsyncCallback && typeof args[args.length - 1] != "function") { + args.push(null); + } + return args; + } + let success = check(0, 0); + if (!success) { + this.throwError(context, "Incorrect argument types"); + } + + // Now we normalize (and fully type check) all non-omitted arguments. + fixedArgs = fixedArgs.map((arg, parameterIndex) => { + if (arg === null) { + return null; + } + let parameter = this.parameters[parameterIndex]; + let r = parameter.type.normalize(arg, context); + if (r.error) { + this.throwError( + context, + `Type error for parameter ${parameter.name} (${forceString(r.error)})` + ); + } + return r.value; + }); + + return fixedArgs; + } +} + +// Represents a "function" defined in a schema namespace. +FunctionEntry = class FunctionEntry extends CallEntry { + static parseSchema(root, schema, path) { + // When not in DEBUG mode, we just need to know *if* this returns. + /** @type {boolean|object} */ + let returns = !!schema.returns; + if (DEBUG && "returns" in schema) { + returns = { + type: root.parseSchema(schema.returns, path, ["optional", "name"]), + optional: schema.returns.optional || false, + name: "result", + }; + } + + return new this( + schema, + path, + schema.name, + root.parseSchema(schema, path, [ + "name", + "unsupported", + "returns", + "permissions", + "allowAmbiguousOptionalArguments", + "allowCrossOriginArguments", + ]), + schema.unsupported || false, + schema.allowAmbiguousOptionalArguments || false, + schema.allowCrossOriginArguments || false, + returns, + schema.permissions || null + ); + } + + constructor( + schema, + path, + name, + type, + unsupported, + allowAmbiguousOptionalArguments, + allowCrossOriginArguments, + returns, + permissions + ) { + super(schema, path, name, type.parameters, allowAmbiguousOptionalArguments); + this.unsupported = unsupported; + this.returns = returns; + this.permissions = permissions; + this.allowCrossOriginArguments = allowCrossOriginArguments; + + this.isAsync = type.isAsync; + this.hasAsyncCallback = type.hasAsyncCallback; + this.requireUserInput = type.requireUserInput; + } + + checkValue({ type, optional, name }, value, context) { + if (optional && value == null) { + return; + } + if ( + type.reference === "ExtensionPanel" || + type.reference === "ExtensionSidebarPane" || + type.reference === "Port" + ) { + // TODO: We currently treat objects with functions as SubModuleType, + // which is just wrong, and a bigger yak. Skipping for now. + return; + } + const { error } = type.normalize(value, context); + if (error) { + this.throwError( + context, + `Type error for ${name} value (${forceString(error)})` + ); + } + } + + checkCallback(args, context) { + const callback = this.parameters[this.parameters.length - 1]; + for (const [i, param] of callback.type.parameters.entries()) { + this.checkValue(param, args[i], context); + } + } + + getDescriptor(path, context) { + let apiImpl = context.getImplementation(path.join("."), this.name); + + let stub; + if (this.isAsync) { + stub = (...args) => { + this.checkDeprecated(context); + let actuals = this.checkParameters(args, context); + let callback = null; + if (this.hasAsyncCallback) { + callback = actuals.pop(); + } + if (callback === null && context.isChromeCompat) { + // We pass an empty stub function as a default callback for + // the `chrome` API, so promise objects are not returned, + // and lastError values are reported immediately. + callback = () => {}; + } + if (DEBUG && this.hasAsyncCallback && callback) { + let original = callback; + callback = (...args) => { + this.checkCallback(args, context); + original(...args); + }; + } + let result = apiImpl.callAsyncFunction( + actuals, + callback, + this.requireUserInput + ); + if (DEBUG && this.hasAsyncCallback && !callback) { + return result.then(result => { + this.checkCallback([result], context); + return result; + }); + } + return result; + }; + } else if (!this.returns) { + stub = (...args) => { + this.checkDeprecated(context); + let actuals = this.checkParameters(args, context); + return apiImpl.callFunctionNoReturn(actuals); + }; + } else { + stub = (...args) => { + this.checkDeprecated(context); + let actuals = this.checkParameters(args, context); + let result = apiImpl.callFunction(actuals); + if (DEBUG && this.returns) { + this.checkValue(this.returns, result, context); + } + return result; + }; + } + + return { + descriptor: { + value: Cu.exportFunction(stub, context.cloneScope, { + allowCrossOriginArguments: this.allowCrossOriginArguments, + }), + }, + revoke() { + apiImpl.revoke(); + apiImpl = null; + }, + }; + } +}; + +// Represents an "event" defined in a schema namespace. +// +// TODO Bug 1369722: we should be able to remove the eslint-disable-line that follows +// once Bug 1369722 has been fixed. +// eslint-disable-next-line no-global-assign +Event = class Event extends CallEntry { + static parseSchema(root, event, path) { + let extraParameters = Array.from(event.extraParameters || [], param => ({ + type: root.parseSchema(param, path, ["name", "optional", "default"]), + name: param.name, + optional: param.optional || false, + default: param.default == undefined ? null : param.default, + })); + + let extraProperties = [ + "name", + "unsupported", + "permissions", + "extraParameters", + // We ignore these properties for now. + "returns", + "filters", + ]; + + return new this( + event, + path, + event.name, + root.parseSchema(event, path, extraProperties), + extraParameters, + event.unsupported || false, + event.permissions || null + ); + } + + constructor( + schema, + path, + name, + type, + extraParameters, + unsupported, + permissions + ) { + super(schema, path, name, extraParameters); + this.type = type; + this.unsupported = unsupported; + this.permissions = permissions; + } + + checkListener(listener, context) { + let r = this.type.normalize(listener, context); + if (r.error) { + this.throwError(context, "Invalid listener"); + } + return r.value; + } + + getDescriptor(path, context) { + let apiImpl = context.getImplementation(path.join("."), this.name); + + let addStub = (listener, ...args) => { + listener = this.checkListener(listener, context); + let actuals = this.checkParameters(args, context); + apiImpl.addListener(listener, actuals); + }; + + let removeStub = listener => { + listener = this.checkListener(listener, context); + apiImpl.removeListener(listener); + }; + + let hasStub = listener => { + listener = this.checkListener(listener, context); + return apiImpl.hasListener(listener); + }; + + let obj = Cu.createObjectIn(context.cloneScope); + + Cu.exportFunction(addStub, obj, { defineAs: "addListener" }); + Cu.exportFunction(removeStub, obj, { defineAs: "removeListener" }); + Cu.exportFunction(hasStub, obj, { defineAs: "hasListener" }); + + return { + descriptor: { value: obj }, + revoke() { + apiImpl.revoke(); + apiImpl = null; + + let unwrapped = ChromeUtils.waiveXrays(obj); + delete unwrapped.addListener; + delete unwrapped.removeListener; + delete unwrapped.hasListener; + }, + }; + } +}; + +const TYPES = Object.freeze( + Object.assign(Object.create(null), { + any: AnyType, + array: ArrayType, + boolean: BooleanType, + function: FunctionType, + integer: IntegerType, + null: NullType, + number: NumberType, + object: ObjectType, + string: StringType, + }) +); + +const LOADERS = { + events: "loadEvent", + functions: "loadFunction", + properties: "loadProperty", + types: "loadType", +}; + +class Namespace extends Map { + constructor(root, name, path) { + super(); + + this.root = root; + + this._lazySchemas = []; + this.initialized = false; + + this.name = name; + this.path = name ? [...path, name] : [...path]; + + this.superNamespace = null; + + this.min_manifest_version = MIN_MANIFEST_VERSION; + this.max_manifest_version = MAX_MANIFEST_VERSION; + + this.permissions = null; + this.allowedContexts = []; + this.defaultContexts = []; + } + + /** + * Adds a JSON Schema object to the set of schemas that represent this + * namespace. + * + * @param {object} schema + * A JSON schema object which partially describes this + * namespace. + */ + addSchema(schema) { + this._lazySchemas.push(schema); + + for (let prop of [ + "permissions", + "allowedContexts", + "defaultContexts", + "min_manifest_version", + "max_manifest_version", + ]) { + if (schema[prop]) { + this[prop] = schema[prop]; + } + } + + if (schema.$import) { + this.superNamespace = this.root.getNamespace(schema.$import); + } + } + + /** + * Initializes the keys of this namespace based on the schema objects + * added via previous `addSchema` calls. + */ + init() { + if (this.initialized) { + return; + } + + if (this.superNamespace) { + this._lazySchemas.unshift(...this.superNamespace._lazySchemas); + } + + // Keep in sync with LOADERS above. + this.types = new DefaultMap(() => []); + this.properties = new DefaultMap(() => []); + this.functions = new DefaultMap(() => []); + this.events = new DefaultMap(() => []); + + for (let schema of this._lazySchemas) { + for (let type of schema.types || []) { + if (!type.unsupported) { + this.types.get(type.$extend || type.id).push(type); + } + } + + for (let [name, prop] of Object.entries(schema.properties || {})) { + if (!prop.unsupported) { + this.properties.get(name).push(prop); + } + } + + for (let fun of schema.functions || []) { + if (!fun.unsupported) { + this.functions.get(fun.name).push(fun); + } + } + + for (let event of schema.events || []) { + if (!event.unsupported) { + this.events.get(event.name).push(event); + } + } + } + + // For each type of top-level property in the schema object, iterate + // over all properties of that type, and create a temporary key for + // each property pointing to its type. Those temporary properties + // are later used to instantiate an Entry object based on the actual + // schema object. + for (let type of Object.keys(LOADERS)) { + for (let key of this[type].keys()) { + this.set(key, type); + } + } + + this.initialized = true; + + if (DEBUG) { + for (let key of this.keys()) { + this.get(key); + } + } + } + + /** + * Initializes the value of a given key, by parsing the schema object + * associated with it and replacing its temporary value with an `Entry` + * instance. + * + * @param {string} key + * The name of the property to initialize. + * @param {string} type + * The type of property the key represents. Must have a + * corresponding entry in the `LOADERS` object, pointing to the + * initialization method for that type. + * + * @returns {Entry} + */ + initKey(key, type) { + let loader = LOADERS[type]; + + for (let schema of this[type].get(key)) { + this.set(key, this[loader](key, schema)); + } + + return this.get(key); + } + + loadType(name, type) { + if ("$extend" in type) { + return this.extendType(type); + } + return this.root.parseSchema(type, this.path, ["id"]); + } + + extendType(type) { + let targetType = this.get(type.$extend); + + // Only allow extending object and choices types for now. + if (targetType instanceof ObjectType) { + type.type = "object"; + } else if (DEBUG) { + if (!targetType) { + throw new Error( + `Internal error: Attempt to extend a nonexistent type ${type.$extend}` + ); + } else if (!(targetType instanceof ChoiceType)) { + throw new Error( + `Internal error: Attempt to extend a non-extensible type ${type.$extend}` + ); + } + } + + let parsed = this.root.parseSchema(type, this.path, ["$extend"]); + + if (DEBUG && parsed.constructor !== targetType.constructor) { + throw new Error(`Internal error: Bad attempt to extend ${type.$extend}`); + } + + targetType.extend(parsed); + + return targetType; + } + + loadProperty(name, prop) { + if ("$ref" in prop) { + if (!prop.unsupported) { + return new SubModuleProperty( + this.root, + prop, + this.path, + name, + prop.$ref, + prop.properties || {}, + prop.permissions || null + ); + } + } else if ("value" in prop) { + return new ValueProperty(prop, name, prop.value); + } else { + // We ignore the "optional" attribute on properties since we + // don't inject anything here anyway. + let type = this.root.parseSchema( + prop, + [this.name], + ["optional", "permissions", "writable"] + ); + return new TypeProperty( + prop, + this.path, + name, + type, + prop.writable || false, + prop.permissions || null + ); + } + } + + loadFunction(name, fun) { + return FunctionEntry.parseSchema(this.root, fun, this.path); + } + + loadEvent(name, event) { + return Event.parseSchema(this.root, event, this.path); + } + + /** + * Injects the properties of this namespace into the given object. + * + * @param {object} dest + * The object into which to inject the namespace properties. + * @param {InjectionContext} context + * The injection context with which to inject the properties. + */ + injectInto(dest, context) { + for (let name of this.keys()) { + // If the entry does not match the manifest version do not + // inject the property. This prevents the item from being + // enumerable in the namespace object. We cannot accomplish + // this inside exportLazyProperty, it specifically injects + // an enumerable object. + let entry = this.get(name); + if (!context.matchManifestVersion(entry)) { + continue; + } + exportLazyProperty(dest, name, () => { + let entry = this.get(name); + + return context.getDescriptor(entry, dest, name, this.path, this); + }); + } + } + + getDescriptor(path, context) { + let obj = Cu.createObjectIn(context.cloneScope); + + let ns = context.schemaRoot.getNamespace(this.path.join(".")); + ns.injectInto(obj, context); + + // Only inject the namespace object if it isn't empty. + if (Object.keys(obj).length) { + return { + descriptor: { value: obj }, + }; + } + } + + keys() { + this.init(); + return super.keys(); + } + + /** @returns {Generator<[string, Entry]>} */ + *entries() { + for (let key of this.keys()) { + yield [key, this.get(key)]; + } + } + + get(key) { + this.init(); + let value = super.get(key); + + // The initial values of lazily-initialized schema properties are + // strings, pointing to the type of property, corresponding to one + // of the entries in the `LOADERS` object. + if (typeof value === "string") { + value = this.initKey(key, value); + } + + return value; + } + + /** + * Returns a Namespace object for the given namespace name. If a + * namespace object with this name does not already exist, it is + * created. If the name contains any '.' characters, namespaces are + * recursively created, for each dot-separated component. + * + * @param {string} name + * The name of the sub-namespace to retrieve. + * @param {boolean} [create = true] + * If true, create any intermediate namespaces which don't + * exist. + * + * @returns {Namespace} + */ + getNamespace(name, create = true) { + let subName; + + let idx = name.indexOf("."); + if (idx > 0) { + subName = name.slice(idx + 1); + name = name.slice(0, idx); + } + + let ns = super.get(name); + if (!ns) { + if (!create) { + return null; + } + ns = new Namespace(this.root, name, this.path); + this.set(name, ns); + } + + if (subName) { + return ns.getNamespace(subName); + } + return ns; + } + + getOwnNamespace(name) { + return this.getNamespace(name); + } + + has(key) { + this.init(); + return super.has(key); + } +} + +/** + * A namespace which combines the children of an arbitrary number of + * sub-namespaces. + */ +class Namespaces extends Namespace { + constructor(root, name, path, namespaces) { + super(root, name, path); + + this.namespaces = namespaces; + } + + injectInto(obj, context) { + for (let ns of this.namespaces) { + ns.injectInto(obj, context); + } + } +} + +/** + * A root schema which combines the contents of an arbitrary number of base + * schema roots. + */ +class SchemaRoots extends Namespaces { + constructor(root, bases) { + bases = bases.map(base => base.rootSchema || base); + + super(null, "", [], bases); + + this.root = root; + this.bases = bases; + this._namespaces = new Map(); + } + + _getNamespace(name, create) { + let results = []; + for (let root of this.bases) { + let ns = root.getNamespace(name, create); + if (ns) { + results.push(ns); + } + } + + if (results.length == 1) { + return results[0]; + } + + if (results.length) { + return new Namespaces(this.root, name, name.split("."), results); + } + return null; + } + + getNamespace(name, create) { + let ns = this._namespaces.get(name); + if (!ns) { + ns = this._getNamespace(name, create); + if (ns) { + this._namespaces.set(name, ns); + } + } + return ns; + } + + *getNamespaces(name) { + for (let root of this.bases) { + yield* root.getNamespaces(name); + } + } +} + +/** + * A root schema namespace containing schema data which is isolated from data in + * other schema roots. May extend a base namespace, in which case schemas in + * this root may refer to types in a base, but not vice versa. + * + * @param {SchemaRoot|Array|null} base + * A base schema root (or roots) from which to derive, or null. + * @param {Map} schemaJSON + * A map of schema URLs and corresponding JSON blobs from which to + * populate this root namespace. + */ +export class SchemaRoot extends Namespace { + constructor(base, schemaJSON) { + super(null, "", []); + + if (Array.isArray(base)) { + base = new SchemaRoots(this, base); + } + + this.root = this; + this.base = base; + this.schemaJSON = schemaJSON; + } + + *getNamespaces(path) { + let name = path.join("."); + + let ns = this.getNamespace(name, false); + if (ns) { + yield ns; + } + + if (this.base) { + yield* this.base.getNamespaces(name); + } + } + + /** + * Returns the sub-namespace with the given name. If the given namespace + * doesn't already exist, attempts to find it in the base SchemaRoot before + * creating a new empty namespace. + * + * @param {string} name + * The namespace to retrieve. + * @param {boolean} [create = true] + * If true, an empty namespace should be created if one does not + * already exist. + * @returns {Namespace|null} + */ + getNamespace(name, create = true) { + let ns = super.getNamespace(name, false); + if (ns) { + return ns; + } + + ns = this.base && this.base.getNamespace(name, false); + if (ns) { + return ns; + } + return create && super.getNamespace(name, create); + } + + /** + * Like getNamespace, but does not take the base SchemaRoot into account. + * + * @param {string} name + * The namespace to retrieve. + * @returns {Namespace} + */ + getOwnNamespace(name) { + return super.getNamespace(name); + } + + parseSchema(schema, path, extraProperties = []) { + let allowedProperties = DEBUG && new Set(extraProperties); + + if ("choices" in schema) { + return ChoiceType.parseSchema(this, schema, path, allowedProperties); + } else if ("$ref" in schema) { + return RefType.parseSchema(this, schema, path, allowedProperties); + } + + let type = TYPES[schema.type]; + + if (DEBUG) { + allowedProperties.add("type"); + + if (!("type" in schema)) { + throw new Error(`Unexpected value for type: ${JSON.stringify(schema)}`); + } + + if (!type) { + throw new Error(`Unexpected type ${schema.type}`); + } + } + + return type.parseSchema(this, schema, path, allowedProperties); + } + + parseSchemas() { + for (let [key, schema] of this.schemaJSON.entries()) { + try { + if (typeof schema.deserialize === "function") { + schema = schema.deserialize(globalThis, isParentProcess); + + // If we're in the parent process, we need to keep the + // StructuredCloneHolder blob around in order to send to future child + // processes. If we're in a child, we have no further use for it, so + // just store the deserialized schema data in its place. + if (!isParentProcess) { + this.schemaJSON.set(key, schema); + } + } + + this.loadSchema(schema); + } catch (e) { + Cu.reportError(e); + } + } + } + + loadSchema(json) { + for (let namespace of json) { + this.getOwnNamespace(namespace.namespace).addSchema(namespace); + } + } + + /** + * Checks whether a given object has the necessary permissions to + * expose the given namespace. + * + * @param {string} namespace + * The top-level namespace to check permissions for. + * @param {object} wrapperFuncs + * Wrapper functions for the given context. + * @param {Function} wrapperFuncs.hasPermission + * A function which, when given a string argument, returns true + * if the context has the given permission. + * @returns {boolean} + * True if the context has permission for the given namespace. + */ + checkPermissions(namespace, wrapperFuncs) { + let ns = this.getNamespace(namespace); + if (ns && ns.permissions) { + return ns.permissions.some(perm => wrapperFuncs.hasPermission(perm)); + } + return true; + } + + /** + * Inject registered extension APIs into `dest`. + * + * @param {object} dest The root namespace for the APIs. + * This object is usually exposed to extensions as "chrome" or "browser". + * @param {object} wrapperFuncs An implementation of the InjectionContext + * interface, which runs the actual functionality of the generated API. + */ + inject(dest, wrapperFuncs) { + let context = new InjectionContext(wrapperFuncs, this); + + this.injectInto(dest, context); + } + + injectInto(dest, context) { + // For schema graphs where multiple schema roots have the same base, don't + // inject it more than once. + + if (!context.injectedRoots.has(this)) { + context.injectedRoots.add(this); + if (this.base) { + this.base.injectInto(dest, context); + } + super.injectInto(dest, context); + } + } + + /** + * Normalize `obj` according to the loaded schema for `typeName`. + * + * @param {object} obj The object to normalize against the schema. + * @param {string} typeName The name in the format namespace.propertyname + * @param {object} context An implementation of Context. Any validation errors + * are reported to the given context. + * @returns {object} The normalized object. + */ + normalize(obj, typeName, context) { + let [namespaceName, prop] = typeName.split("."); + let ns = this.getNamespace(namespaceName); + let type = ns.get(prop); + + let result = type.normalize(obj, new Context(context)); + if (result.error) { + return { error: forceString(result.error) }; + } + return result; + } +} + +export var Schemas = { + initialized: false, + + REVOKE: Symbol("@@revoke"), + + // Maps a schema URL to the JSON contained in that schema file. This + // is useful for sending the JSON across processes. + schemaJSON: new Map(), + + // A map of schema JSON which should be available in all content processes. + contentSchemaJSON: new Map(), + + // A map of schema JSON which should only be available to extension processes. + privilegedSchemaJSON: new Map(), + + _rootSchema: null, + + // A weakmap for the validation Context class instances given an extension + // context (keyed by the extensin context instance). + // This is used instead of the InjectionContext for webIDL API validation + // and normalization (see Schemas.checkParameters). + paramsValidationContexts: new DefaultWeakMap( + extContext => new Context(extContext) + ), + + get rootSchema() { + if (!this.initialized) { + this.init(); + } + if (!this._rootSchema) { + this._rootSchema = new SchemaRoot(null, this.schemaJSON); + this._rootSchema.parseSchemas(); + } + return this._rootSchema; + }, + + getNamespace(name) { + return this.rootSchema.getNamespace(name); + }, + + init() { + if (this.initialized) { + return; + } + this.initialized = true; + + if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT) { + let addSchemas = schemas => { + for (let [key, value] of schemas.entries()) { + this.schemaJSON.set(key, value); + } + }; + + if (WebExtensionPolicy.isExtensionProcess || DEBUG) { + addSchemas(Services.cpmm.sharedData.get(KEY_PRIVILEGED_SCHEMAS)); + } + + let schemas = Services.cpmm.sharedData.get(KEY_CONTENT_SCHEMAS); + if (schemas) { + addSchemas(schemas); + } + } + }, + + _loadCachedSchemasPromise: null, + loadCachedSchemas() { + if (!this._loadCachedSchemasPromise) { + this._loadCachedSchemasPromise = lazy.StartupCache.schemas + .getAll() + .then(results => { + return results; + }); + } + + return this._loadCachedSchemasPromise; + }, + + addSchema(url, schema, content = false) { + this.schemaJSON.set(url, schema); + + if (content) { + this.contentSchemaJSON.set(url, schema); + } else { + this.privilegedSchemaJSON.set(url, schema); + } + + if (this._rootSchema) { + throw new Error("Schema loaded after root schema populated"); + } + }, + + updateSharedSchemas() { + let { sharedData } = Services.ppmm; + + sharedData.set(KEY_CONTENT_SCHEMAS, this.contentSchemaJSON); + sharedData.set(KEY_PRIVILEGED_SCHEMAS, this.privilegedSchemaJSON); + }, + + fetch(url) { + return readJSONAndBlobbify(url); + }, + + processSchema(json) { + return blobbify(json); + }, + + async load(url, content = false) { + if (!isParentProcess) { + return; + } + + const startTime = Cu.now(); + let schemaCache = await this.loadCachedSchemas(); + const fromCache = schemaCache.has(url); + + let blob = + schemaCache.get(url) || + (await lazy.StartupCache.schemas.get(url, readJSONAndBlobbify)); + + if (!this.schemaJSON.has(url)) { + this.addSchema(url, blob, content); + } + + ChromeUtils.addProfilerMarker( + "ExtensionSchemas", + { startTime }, + `load ${url}, from cache: ${fromCache}` + ); + }, + + /** + * Checks whether a given object has the necessary permissions to + * expose the given namespace. + * + * @param {string} namespace + * The top-level namespace to check permissions for. + * @param {object} wrapperFuncs + * Wrapper functions for the given context. + * @param {Function} wrapperFuncs.hasPermission + * A function which, when given a string argument, returns true + * if the context has the given permission. + * @returns {boolean} + * True if the context has permission for the given namespace. + */ + checkPermissions(namespace, wrapperFuncs) { + return this.rootSchema.checkPermissions(namespace, wrapperFuncs); + }, + + /** + * Returns a sorted array of permission names for the given permission types. + * + * @param {Array} types An array of permission types, defaults to all permissions. + * @returns {Array} sorted array of permission names + */ + getPermissionNames( + types = [ + "Permission", + "OptionalPermission", + "PermissionNoPrompt", + "OptionalPermissionNoPrompt", + "PermissionPrivileged", + ] + ) { + const ns = this.getNamespace("manifest"); + let names = []; + for (let typeName of types) { + for (let choice of ns + .get(typeName) + .choices.filter(choice => choice.enumeration)) { + names = names.concat(choice.enumeration); + } + } + return names.sort(); + }, + + exportLazyGetter, + + /** + * Inject registered extension APIs into `dest`. + * + * @param {object} dest The root namespace for the APIs. + * This object is usually exposed to extensions as "chrome" or "browser". + * @param {object} wrapperFuncs An implementation of the InjectionContext + * interface, which runs the actual functionality of the generated API. + */ + inject(dest, wrapperFuncs) { + this.rootSchema.inject(dest, wrapperFuncs); + }, + + /** + * Normalize `obj` according to the loaded schema for `typeName`. + * + * @param {object} obj The object to normalize against the schema. + * @param {string} typeName The name in the format namespace.propertyname + * @param {object} context An implementation of Context. Any validation errors + * are reported to the given context. + * @returns {object} The normalized object. + */ + normalize(obj, typeName, context) { + return this.rootSchema.normalize(obj, typeName, context); + }, + + /** + * Validate and normalize the arguments for an API request originated + * from the webIDL API bindings. + * + * This provides for calls originating through WebIDL the parameters + * validation and normalization guarantees that the ext-APINAMESPACE.js + * scripts expects (what InjectionContext does for the regular bindings). + * + * @param {object} extContext + * @param {mozIExtensionAPIRequest } apiRequest + * + * @returns {Array} Normalized arguments array. + */ + checkWebIDLRequestParameters(extContext, apiRequest) { + const getSchemaForProperty = (schemaObj, propName, schemaPath) => { + if (schemaObj instanceof Namespace) { + return schemaObj?.get(propName); + } else if (schemaObj instanceof SubModuleProperty) { + for (const fun of schemaObj.targetType.functions) { + if (fun.name === propName) { + return fun; + } + } + + for (const fun of schemaObj.targetType.events) { + if (fun.name === propName) { + return fun; + } + } + } else if (schemaObj instanceof Event) { + return schemaObj; + } + + const schemaPathType = schemaObj?.constructor.name; + throw new Error( + `API Schema for "${propName}" not found in ${schemaPath} (${schemaPath} type is ${schemaPathType})` + ); + }; + const { requestType, apiNamespace, apiName } = apiRequest; + + let [ns, ...rest] = ( + ["addListener", "removeListener"].includes(requestType) + ? `${apiNamespace}.${apiName}.${requestType}` + : `${apiNamespace}.${apiName}` + ).split("."); + let apiSchema = this.getNamespace(ns); + + // Keep track of the current schema path, populated while navigating the nested API schema + // data and then used to include the full path to the API schema that is hitting unexpected + // errors due to schema data not found or an unexpected schema type. + let schemaPath = [ns]; + + while (rest.length) { + // Nested property as namespace (e.g. used for proxy.settings requests). + if (!apiSchema) { + throw new Error(`API Schema not found for ${schemaPath.join(".")}`); + } + + let [propName, ...newRest] = rest; + rest = newRest; + + apiSchema = getSchemaForProperty( + apiSchema, + propName, + schemaPath.join(".") + ); + schemaPath.push(propName); + } + + if (!apiSchema) { + throw new Error(`API Schema not found for ${schemaPath.join(".")}`); + } + + if (!apiSchema.checkParameters) { + throw new Error( + `Unexpected API Schema type for ${schemaPath.join( + "." + )} (${schemaPath.join(".")} type is ${apiSchema.constructor.name})` + ); + } + + return apiSchema.checkParameters( + apiRequest.args, + this.paramsValidationContexts.get(extContext) + ); + }, +}; diff --git a/toolkit/components/extensions/WebExtensionContentScript.h b/toolkit/components/extensions/WebExtensionContentScript.h new file mode 100644 index 0000000000..bf6975df08 --- /dev/null +++ b/toolkit/components/extensions/WebExtensionContentScript.h @@ -0,0 +1,216 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#ifndef mozilla_extensions_WebExtensionContentScript_h +#define mozilla_extensions_WebExtensionContentScript_h + +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/WebExtensionContentScriptBinding.h" + +#include "jspubtd.h" + +#include "mozilla/Maybe.h" +#include "mozilla/Variant.h" +#include "mozilla/extensions/MatchGlob.h" +#include "mozilla/extensions/MatchPattern.h" +#include "nsCOMPtr.h" +#include "nsCycleCollectionParticipant.h" +#include "nsISupports.h" +#include "nsIDocShell.h" +#include "nsPIDOMWindow.h" +#include "nsWrapperCache.h" + +class nsILoadInfo; +class nsPIDOMWindowOuter; + +namespace mozilla { +namespace dom { +class WindowGlobalChild; +} + +namespace extensions { + +using dom::Nullable; +using ContentScriptInit = dom::WebExtensionContentScriptInit; + +class WebExtensionPolicy; + +class MOZ_STACK_CLASS DocInfo final { + public: + DocInfo(const URLInfo& aURL, nsILoadInfo* aLoadInfo); + + MOZ_IMPLICIT DocInfo(nsPIDOMWindowOuter* aWindow); + + const URLInfo& URL() const { return mURL; } + + // The principal of the document, or the expected principal of a request. + // May be null for non-DOMWindow DocInfo objects unless + // URL().InheritsPrincipal() is true. + nsIPrincipal* Principal() const; + + // Returns the URL of the document's principal. Note that this must *only* + // be called for content principals. + const URLInfo& PrincipalURL() const; + + bool IsTopLevel() const; + bool IsSameOriginWithTop() const; + bool ShouldMatchActiveTabPermission() const; + + uint64_t FrameID() const; + + nsPIDOMWindowOuter* GetWindow() const { + if (mObj.is()) { + return mObj.as(); + } + return nullptr; + } + + nsILoadInfo* GetLoadInfo() const { + if (mObj.is()) { + return mObj.as(); + } + return nullptr; + } + + already_AddRefed GetLoadContext() const { + nsCOMPtr loadContext; + if (nsPIDOMWindowOuter* window = GetWindow()) { + nsIDocShell* docShell = window->GetDocShell(); + loadContext = do_QueryInterface(docShell); + } else if (nsILoadInfo* loadInfo = GetLoadInfo()) { + nsCOMPtr requestingContext = loadInfo->GetLoadingContext(); + loadContext = do_QueryInterface(requestingContext); + } + return loadContext.forget(); + } + + private: + void SetURL(const URLInfo& aURL); + + const URLInfo mURL; + mutable Maybe mPrincipalURL; + + mutable Maybe mIsTopLevel; + + mutable Maybe> mPrincipal; + mutable Maybe mFrameID; + + using Window = nsPIDOMWindowOuter*; + using LoadInfo = nsILoadInfo*; + + const Variant mObj; +}; + +class MozDocumentMatcher : public nsISupports, public nsWrapperCache { + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(MozDocumentMatcher) + + using MatchGlobArray = nsTArray>; + + static already_AddRefed Constructor( + dom::GlobalObject& aGlobal, const dom::MozDocumentMatcherInit& aInit, + ErrorResult& aRv); + + bool Matches(const DocInfo& aDoc, bool aIgnorePermissions) const; + bool Matches(const DocInfo& aDoc) const { return Matches(aDoc, false); } + + bool MatchesURI(const URLInfo& aURL, bool aIgnorePermissions) const; + bool MatchesURI(const URLInfo& aURL) const { return MatchesURI(aURL, false); } + + bool MatchesWindowGlobal(dom::WindowGlobalChild& aWindow, + bool aIgnorePermissions) const; + + WebExtensionPolicy* GetExtension() { return mExtension; } + + WebExtensionPolicy* Extension() { return mExtension; } + const WebExtensionPolicy* Extension() const { return mExtension; } + + bool AllFrames() const { return mAllFrames; } + bool CheckPermissions() const { return mCheckPermissions; } + bool MatchAboutBlank() const { return mMatchAboutBlank; } + + MatchPatternSet* Matches() { return mMatches; } + const MatchPatternSet* GetMatches() const { return mMatches; } + + MatchPatternSet* GetExcludeMatches() { return mExcludeMatches; } + const MatchPatternSet* GetExcludeMatches() const { return mExcludeMatches; } + + Nullable GetFrameID() const { return mFrameID; } + + void GetOriginAttributesPatterns(JSContext* aCx, + JS::MutableHandle aVal, + ErrorResult& aError) const; + + WebExtensionPolicy* GetParentObject() const { return mExtension; } + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle aGivenProto) override; + + protected: + friend class WebExtensionPolicy; + + virtual ~MozDocumentMatcher() = default; + + MozDocumentMatcher(dom::GlobalObject& aGlobal, + const dom::MozDocumentMatcherInit& aInit, bool aRestricted, + ErrorResult& aRv); + + RefPtr mExtension; + + bool mHasActiveTabPermission; + bool mRestricted; + + RefPtr mMatches; + RefPtr mExcludeMatches; + + Nullable mIncludeGlobs; + Nullable mExcludeGlobs; + + bool mAllFrames; + bool mCheckPermissions; + Nullable mFrameID; + bool mMatchAboutBlank; + Nullable> mOriginAttributesPatterns; +}; + +class WebExtensionContentScript final : public MozDocumentMatcher { + public: + using RunAtEnum = dom::ContentScriptRunAt; + + static already_AddRefed Constructor( + dom::GlobalObject& aGlobal, WebExtensionPolicy& aExtension, + const ContentScriptInit& aInit, ErrorResult& aRv); + + RunAtEnum RunAt() const { return mRunAt; } + + void GetCssPaths(nsTArray& aPaths) const { + aPaths.AppendElements(mCssPaths); + } + void GetJsPaths(nsTArray& aPaths) const { + aPaths.AppendElements(mJsPaths); + } + + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle aGivenProto) override; + + protected: + friend class WebExtensionPolicy; + + virtual ~WebExtensionContentScript() = default; + + WebExtensionContentScript(dom::GlobalObject& aGlobal, + WebExtensionPolicy& aExtension, + const ContentScriptInit& aInit, ErrorResult& aRv); + + private: + nsTArray mCssPaths; + nsTArray mJsPaths; + + RunAtEnum mRunAt; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_WebExtensionContentScript_h diff --git a/toolkit/components/extensions/WebExtensionPolicy.cpp b/toolkit/components/extensions/WebExtensionPolicy.cpp new file mode 100644 index 0000000000..1b999ab9a3 --- /dev/null +++ b/toolkit/components/extensions/WebExtensionPolicy.cpp @@ -0,0 +1,1137 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#include "MainThreadUtils.h" +#include "mozilla/ExtensionPolicyService.h" +#include "mozilla/extensions/DocumentObserver.h" +#include "mozilla/extensions/WebExtensionContentScript.h" +#include "mozilla/extensions/WebExtensionPolicy.h" + +#include "mozilla/AddonManagerWebAPI.h" +#include "mozilla/BasePrincipal.h" +#include "mozilla/dom/WindowGlobalChild.h" +#include "mozilla/ResultExtensions.h" +#include "mozilla/StaticPrefs_extensions.h" +#include "mozilla/Try.h" +#include "nsContentUtils.h" +#include "nsEscape.h" +#include "nsGlobalWindowInner.h" +#include "nsIObserver.h" +#include "nsISubstitutingProtocolHandler.h" +#include "nsLiteralString.h" +#include "nsNetUtil.h" +#include "nsPrintfCString.h" + +namespace mozilla { +namespace extensions { + +using namespace dom; + +static const char kProto[] = "moz-extension"; + +static const char kBackgroundScriptTypeDefault[] = "text/javascript"; + +static const char kBackgroundScriptTypeModule[] = "module"; + +static const char kBackgroundPageHTMLStart[] = + "\n\ +\n\ + \n\ + "; + +static const char kBackgroundPageHTMLScript[] = + "\n\ + "; + +static const char kBackgroundPageHTMLEnd[] = + "\n\ + \n\ +"; + +#define BASE_CSP_PREF_V2 "extensions.webextensions.base-content-security-policy" +#define DEFAULT_BASE_CSP_V2 \ + "script-src 'self' https://* http://localhost:* http://127.0.0.1:* " \ + "moz-extension: blob: filesystem: 'unsafe-eval' 'wasm-unsafe-eval' " \ + "'unsafe-inline';" + +#define BASE_CSP_PREF_V3 \ + "extensions.webextensions.base-content-security-policy.v3" +#define DEFAULT_BASE_CSP_V3 "script-src 'self' 'wasm-unsafe-eval';" + +static inline ExtensionPolicyService& EPS() { + return ExtensionPolicyService::GetSingleton(); +} + +static nsISubstitutingProtocolHandler* Proto() { + static nsCOMPtr sHandler; + + if (MOZ_UNLIKELY(!sHandler)) { + nsCOMPtr ios = do_GetIOService(); + MOZ_RELEASE_ASSERT(ios); + + nsCOMPtr handler; + ios->GetProtocolHandler(kProto, getter_AddRefs(handler)); + + sHandler = do_QueryInterface(handler); + MOZ_RELEASE_ASSERT(sHandler); + + ClearOnShutdown(&sHandler); + } + + return sHandler; +} + +bool ParseGlobs(GlobalObject& aGlobal, + Sequence aGlobs, + nsTArray>& aResult, ErrorResult& aRv) { + for (auto& elem : aGlobs) { + if (elem.IsMatchGlob()) { + aResult.AppendElement(elem.GetAsMatchGlob()->Core()); + } else { + RefPtr glob = + new MatchGlobCore(elem.GetAsUTF8String(), true, false, aRv); + if (aRv.Failed()) { + return false; + } + aResult.AppendElement(glob); + } + } + return true; +} + +enum class ErrorBehavior { + CreateEmptyPattern, + Fail, +}; + +already_AddRefed ParseMatches( + GlobalObject& aGlobal, + const OwningMatchPatternSetOrStringSequence& aMatches, + const MatchPatternOptions& aOptions, ErrorBehavior aErrorBehavior, + ErrorResult& aRv) { + if (aMatches.IsMatchPatternSet()) { + return do_AddRef(aMatches.GetAsMatchPatternSet().get()); + } + + const auto& strings = aMatches.GetAsStringSequence(); + + nsTArray patterns; + if (!patterns.SetCapacity(strings.Length(), fallible)) { + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + return nullptr; + } + + for (auto& string : strings) { + OwningStringOrMatchPattern elt; + elt.SetAsString() = string; + patterns.AppendElement(elt); + } + + RefPtr result = + MatchPatternSet::Constructor(aGlobal, patterns, aOptions, aRv); + + if (aRv.Failed() && aErrorBehavior == ErrorBehavior::CreateEmptyPattern) { + aRv.SuppressException(); + result = MatchPatternSet::Constructor(aGlobal, {}, aOptions, aRv); + } + + return result.forget(); +} + +WebAccessibleResource::WebAccessibleResource( + GlobalObject& aGlobal, const WebAccessibleResourceInit& aInit, + ErrorResult& aRv) { + ParseGlobs(aGlobal, aInit.mResources, mWebAccessiblePaths, aRv); + if (aRv.Failed()) { + return; + } + + if (!aInit.mMatches.IsNull()) { + MatchPatternOptions options; + options.mRestrictSchemes = true; + RefPtr matches = + ParseMatches(aGlobal, aInit.mMatches.Value(), options, + ErrorBehavior::CreateEmptyPattern, aRv); + MOZ_DIAGNOSTIC_ASSERT(!aRv.Failed()); + mMatches = matches->Core(); + } + + if (!aInit.mExtension_ids.IsNull()) { + mExtensionIDs = new AtomSet(aInit.mExtension_ids.Value()); + } +} + +bool WebAccessibleResource::IsExtensionMatch(const URLInfo& aURI) { + if (!mExtensionIDs) { + return false; + } + RefPtr policy = + ExtensionPolicyService::GetCoreByHost(aURI.Host()); + return policy && (mExtensionIDs->Contains(nsGkAtoms::_asterisk) || + mExtensionIDs->Contains(policy->Id())); +} + +/***************************************************************************** + * WebExtensionPolicyCore + *****************************************************************************/ + +WebExtensionPolicyCore::WebExtensionPolicyCore(GlobalObject& aGlobal, + WebExtensionPolicy* aPolicy, + const WebExtensionInit& aInit, + ErrorResult& aRv) + : mPolicy(aPolicy), + mId(NS_AtomizeMainThread(aInit.mId)), + mName(aInit.mName), + mType(NS_AtomizeMainThread(aInit.mType)), + mManifestVersion(aInit.mManifestVersion), + mExtensionPageCSP(aInit.mExtensionPageCSP), + mIsPrivileged(aInit.mIsPrivileged), + mTemporarilyInstalled(aInit.mTemporarilyInstalled), + mBackgroundWorkerScript(aInit.mBackgroundWorkerScript), + mIgnoreQuarantine(aInit.mIsPrivileged || aInit.mIgnoreQuarantine), + mPermissions(new AtomSet(aInit.mPermissions)) { + // In practice this is not necessary, but in tests where the uuid + // passed in is not lowercased various tests can fail. + ToLowerCase(aInit.mMozExtensionHostname, mHostname); + + // Initialize the base CSP and extension page CSP + if (mManifestVersion < 3) { + nsresult rv = Preferences::GetString(BASE_CSP_PREF_V2, mBaseCSP); + if (NS_FAILED(rv)) { + mBaseCSP = NS_LITERAL_STRING_FROM_CSTRING(DEFAULT_BASE_CSP_V2); + } + } else { + nsresult rv = Preferences::GetString(BASE_CSP_PREF_V3, mBaseCSP); + if (NS_FAILED(rv)) { + mBaseCSP = NS_LITERAL_STRING_FROM_CSTRING(DEFAULT_BASE_CSP_V3); + } + } + + if (mExtensionPageCSP.IsVoid()) { + if (mManifestVersion < 3) { + EPS().GetDefaultCSP(mExtensionPageCSP); + } else { + EPS().GetDefaultCSPV3(mExtensionPageCSP); + } + } + + mWebAccessibleResources.SetCapacity(aInit.mWebAccessibleResources.Length()); + for (const auto& resourceInit : aInit.mWebAccessibleResources) { + RefPtr resource = + new WebAccessibleResource(aGlobal, resourceInit, aRv); + if (aRv.Failed()) { + return; + } + mWebAccessibleResources.AppendElement(std::move(resource)); + } + + nsresult rv = NS_NewURI(getter_AddRefs(mBaseURI), aInit.mBaseURL); + if (NS_FAILED(rv)) { + aRv.Throw(rv); + } +} + +bool WebExtensionPolicyCore::SourceMayAccessPath( + const URLInfo& aURI, const nsACString& aPath) const { + if (aURI.Scheme() == nsGkAtoms::moz_extension && + MozExtensionHostname().Equals(aURI.Host())) { + // An extension can always access it's own paths. + return true; + } + // Bug 1786564 Static themes need to allow access to theme resources. + if (Type() == nsGkAtoms::theme) { + RefPtr policyCore = + ExtensionPolicyService::GetCoreByHost(aURI.Host()); + return policyCore != nullptr; + } + + if (ManifestVersion() < 3) { + return IsWebAccessiblePath(aPath); + } + for (const auto& resource : mWebAccessibleResources) { + if (resource->SourceMayAccessPath(aURI, aPath)) { + return true; + } + } + return false; +} + +bool WebExtensionPolicyCore::CanAccessURI(const URLInfo& aURI, bool aExplicit, + bool aCheckRestricted, + bool aAllowFilePermission) const { + if (aCheckRestricted && WebExtensionPolicy::IsRestrictedURI(aURI)) { + return false; + } + if (aCheckRestricted && QuarantinedFromURI(aURI)) { + return false; + } + if (!aAllowFilePermission && aURI.Scheme() == nsGkAtoms::file) { + return false; + } + + AutoReadLock lock(mLock); + return mHostPermissions && mHostPermissions->Matches(aURI, aExplicit); +} + +bool WebExtensionPolicyCore::QuarantinedFromDoc(const DocInfo& aDoc) const { + return QuarantinedFromURI(aDoc.PrincipalURL()); +} + +bool WebExtensionPolicyCore::QuarantinedFromURI(const URLInfo& aURI) const { + return !IgnoreQuarantine() && WebExtensionPolicy::IsQuarantinedURI(aURI); +} + +/***************************************************************************** + * WebExtensionPolicy + *****************************************************************************/ + +WebExtensionPolicy::WebExtensionPolicy(GlobalObject& aGlobal, + const WebExtensionInit& aInit, + ErrorResult& aRv) + : mCore(new WebExtensionPolicyCore(aGlobal, this, aInit, aRv)), + mLocalizeCallback(aInit.mLocalizeCallback) { + if (aRv.Failed()) { + return; + } + + MatchPatternOptions options; + options.mRestrictSchemes = !HasPermission(nsGkAtoms::mozillaAddons); + + // Set host permissions with SetAllowedOrigins to make sure the copy in core + // and WebExtensionPolicy stay in sync. + RefPtr hostPermissions = + ParseMatches(aGlobal, aInit.mAllowedOrigins, options, + ErrorBehavior::CreateEmptyPattern, aRv); + if (aRv.Failed()) { + return; + } + SetAllowedOrigins(*hostPermissions); + + if (!aInit.mBackgroundScripts.IsNull()) { + mBackgroundScripts.SetValue().AppendElements( + aInit.mBackgroundScripts.Value()); + } + + mBackgroundTypeModule = aInit.mBackgroundTypeModule; + + mContentScripts.SetCapacity(aInit.mContentScripts.Length()); + for (const auto& scriptInit : aInit.mContentScripts) { + // The activeTab permission is only for dynamically injected scripts, + // it cannot be used for declarative content scripts. + if (scriptInit.mHasActiveTabPermission) { + aRv.Throw(NS_ERROR_INVALID_ARG); + return; + } + + RefPtr contentScript = + new WebExtensionContentScript(aGlobal, *this, scriptInit, aRv); + if (aRv.Failed()) { + return; + } + mContentScripts.AppendElement(std::move(contentScript)); + } + + if (aInit.mReadyPromise.WasPassed()) { + mReadyPromise = &aInit.mReadyPromise.Value(); + } +} + +already_AddRefed WebExtensionPolicy::Constructor( + GlobalObject& aGlobal, const WebExtensionInit& aInit, ErrorResult& aRv) { + RefPtr policy = + new WebExtensionPolicy(aGlobal, aInit, aRv); + if (aRv.Failed()) { + return nullptr; + } + return policy.forget(); +} + +/* static */ +void WebExtensionPolicy::GetActiveExtensions( + dom::GlobalObject& aGlobal, + nsTArray>& aResults) { + EPS().GetAll(aResults); +} + +/* static */ +already_AddRefed WebExtensionPolicy::GetByID( + dom::GlobalObject& aGlobal, const nsAString& aID) { + return do_AddRef(EPS().GetByID(aID)); +} + +/* static */ +already_AddRefed WebExtensionPolicy::GetByHostname( + dom::GlobalObject& aGlobal, const nsACString& aHostname) { + return do_AddRef(EPS().GetByHost(aHostname)); +} + +/* static */ +already_AddRefed WebExtensionPolicy::GetByURI( + dom::GlobalObject& aGlobal, nsIURI* aURI) { + return do_AddRef(EPS().GetByURL(aURI)); +} + +void WebExtensionPolicy::SetActive(bool aActive, ErrorResult& aRv) { + if (aActive == mActive) { + return; + } + + bool ok = aActive ? Enable() : Disable(); + + if (!ok) { + aRv.Throw(NS_ERROR_UNEXPECTED); + } +} + +bool WebExtensionPolicy::Enable() { + MOZ_ASSERT(!mActive); + + if (!EPS().RegisterExtension(*this)) { + return false; + } + + if (XRE_IsParentProcess()) { + // Reserve a BrowsingContextGroup for use by this WebExtensionPolicy. + RefPtr group = BrowsingContextGroup::Create(); + mBrowsingContextGroup = group->MakeKeepAlivePtr(); + } + + Unused << Proto()->SetSubstitution(MozExtensionHostname(), BaseURI()); + + mActive = true; + return true; +} + +bool WebExtensionPolicy::Disable() { + MOZ_ASSERT(mActive); + MOZ_ASSERT(EPS().GetByID(Id()) == this); + + if (!EPS().UnregisterExtension(*this)) { + return false; + } + + if (XRE_IsParentProcess()) { + // Clear our BrowsingContextGroup reference. A new instance will be created + // when the extension is next activated. + mBrowsingContextGroup = nullptr; + } + + Unused << Proto()->SetSubstitution(MozExtensionHostname(), nullptr); + + mActive = false; + return true; +} + +void WebExtensionPolicy::GetURL(const nsAString& aPath, nsAString& aResult, + ErrorResult& aRv) const { + auto result = GetURL(aPath); + if (result.isOk()) { + aResult = result.unwrap(); + } else { + aRv.Throw(result.unwrapErr()); + } +} + +Result WebExtensionPolicy::GetURL( + const nsAString& aPath) const { + nsPrintfCString spec("%s://%s/", kProto, MozExtensionHostname().get()); + + nsCOMPtr uri; + MOZ_TRY(NS_NewURI(getter_AddRefs(uri), spec)); + + MOZ_TRY(uri->Resolve(NS_ConvertUTF16toUTF8(aPath), spec)); + + return NS_ConvertUTF8toUTF16(spec); +} + +void WebExtensionPolicy::SetIgnoreQuarantine(bool aIgnore) { + WebExtensionPolicy_Binding::ClearCachedIgnoreQuarantineValue(this); + mCore->SetIgnoreQuarantine(aIgnore); +} + +void WebExtensionPolicy::RegisterContentScript( + WebExtensionContentScript& script, ErrorResult& aRv) { + // Raise an "invalid argument" error if the script is not related to + // the expected extension or if it is already registered. + if (script.mExtension != this || mContentScripts.Contains(&script)) { + aRv.Throw(NS_ERROR_INVALID_ARG); + return; + } + + RefPtr newScript = &script; + + if (!mContentScripts.AppendElement(std::move(newScript), fallible)) { + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + return; + } + + WebExtensionPolicy_Binding::ClearCachedContentScriptsValue(this); +} + +void WebExtensionPolicy::UnregisterContentScript( + const WebExtensionContentScript& script, ErrorResult& aRv) { + if (script.mExtension != this || !mContentScripts.RemoveElement(&script)) { + aRv.Throw(NS_ERROR_INVALID_ARG); + return; + } + + WebExtensionPolicy_Binding::ClearCachedContentScriptsValue(this); +} + +void WebExtensionPolicy::SetAllowedOrigins(MatchPatternSet& aAllowedOrigins) { + // Make sure to keep the version in `WebExtensionPolicy` (which can be exposed + // back to script using AllowedOrigins()), and the version in + // `WebExtensionPolicyCore` (which is threadsafe) in sync. + AutoWriteLock lock(mCore->mLock); + mHostPermissions = &aAllowedOrigins; + mCore->mHostPermissions = aAllowedOrigins.Core(); +} + +void WebExtensionPolicy::InjectContentScripts(ErrorResult& aRv) { + nsresult rv = EPS().InjectContentScripts(this); + if (NS_FAILED(rv)) { + aRv.Throw(rv); + } +} + +/* static */ +bool WebExtensionPolicy::UseRemoteWebExtensions(GlobalObject& aGlobal) { + return EPS().UseRemoteExtensions(); +} + +/* static */ +bool WebExtensionPolicy::IsExtensionProcess(GlobalObject& aGlobal) { + return EPS().IsExtensionProcess(); +} + +/* static */ +bool WebExtensionPolicy::BackgroundServiceWorkerEnabled(GlobalObject& aGlobal) { + // When MOZ_WEBEXT_WEBIDL_ENABLED is not set at compile time, extension APIs + // are not available to extension service workers. To avoid confusion, the + // extensions.backgroundServiceWorkerEnabled.enabled pref is locked to false + // in modules/libpref/init/all.js when MOZ_WEBEXT_WEBIDL_ENABLED is not set. + return StaticPrefs::extensions_backgroundServiceWorker_enabled_AtStartup(); +} + +/* static */ +bool WebExtensionPolicy::QuarantinedDomainsEnabled(GlobalObject& aGlobal) { + return EPS().GetQuarantinedDomainsEnabled(); +} + +/* static */ +bool WebExtensionPolicy::IsRestrictedDoc(const DocInfo& aDoc) { + // With the exception of top-level about:blank documents with null + // principals, we never match documents that have non-content principals, + // including those with null principals or system principals. + if (aDoc.Principal() && !aDoc.Principal()->GetIsContentPrincipal()) { + return true; + } + + return IsRestrictedURI(aDoc.PrincipalURL()); +} + +/* static */ +bool WebExtensionPolicy::IsRestrictedURI(const URLInfo& aURI) { + RefPtr restrictedDomains = + ExtensionPolicyService::RestrictedDomains(); + + if (restrictedDomains && restrictedDomains->Contains(aURI.HostAtom())) { + return true; + } + + if (AddonManagerWebAPI::IsValidSite(aURI.URI())) { + return true; + } + + return false; +} + +/* static */ +bool WebExtensionPolicy::IsQuarantinedDoc(const DocInfo& aDoc) { + return IsQuarantinedURI(aDoc.PrincipalURL()); +} + +/* static */ +bool WebExtensionPolicy::IsQuarantinedURI(const URLInfo& aURI) { + // Ensure EPS is initialized before asking it about quarantined domains. + Unused << EPS(); + + RefPtr quarantinedDomains = + ExtensionPolicyService::QuarantinedDomains(); + + return quarantinedDomains && quarantinedDomains->Contains(aURI.HostAtom()); +} + +nsCString WebExtensionPolicy::BackgroundPageHTML() const { + nsCString result; + + if (mBackgroundScripts.IsNull()) { + result.SetIsVoid(true); + return result; + } + + result.AppendLiteral(kBackgroundPageHTMLStart); + + const char* scriptType = mBackgroundTypeModule ? kBackgroundScriptTypeModule + : kBackgroundScriptTypeDefault; + + for (auto& script : mBackgroundScripts.Value()) { + nsCString escaped; + nsAppendEscapedHTML(NS_ConvertUTF16toUTF8(script), escaped); + result.AppendPrintf(kBackgroundPageHTMLScript, scriptType, escaped.get()); + } + + result.AppendLiteral(kBackgroundPageHTMLEnd); + return result; +} + +void WebExtensionPolicy::Localize(const nsAString& aInput, + nsString& aOutput) const { + RefPtr callback(mLocalizeCallback); + callback->Call(aInput, aOutput); +} + +JSObject* WebExtensionPolicy::WrapObject(JSContext* aCx, + JS::Handle aGivenProto) { + return WebExtensionPolicy_Binding::Wrap(aCx, this, aGivenProto); +} + +void WebExtensionPolicy::GetContentScripts( + nsTArray>& aScripts) const { + aScripts.AppendElements(mContentScripts); +} + +bool WebExtensionPolicy::PrivateBrowsingAllowed() const { + return HasPermission(nsGkAtoms::privateBrowsingAllowedPermission); +} + +bool WebExtensionPolicy::CanAccessContext(nsILoadContext* aContext) const { + MOZ_ASSERT(aContext); + return PrivateBrowsingAllowed() || !aContext->UsePrivateBrowsing(); +} + +bool WebExtensionPolicy::CanAccessWindow( + const dom::WindowProxyHolder& aWindow) const { + if (PrivateBrowsingAllowed()) { + return true; + } + // match browsing mode with policy + nsIDocShell* docShell = aWindow.get()->GetDocShell(); + nsCOMPtr loadContext = do_QueryInterface(docShell); + return !(loadContext && loadContext->UsePrivateBrowsing()); +} + +void WebExtensionPolicy::GetReadyPromise( + JSContext* aCx, JS::MutableHandle aResult) const { + if (mReadyPromise) { + aResult.set(mReadyPromise->PromiseObj()); + } else { + aResult.set(nullptr); + } +} + +uint64_t WebExtensionPolicy::GetBrowsingContextGroupId() const { + MOZ_ASSERT(XRE_IsParentProcess() && mActive); + return mBrowsingContextGroup ? mBrowsingContextGroup->Id() : 0; +} + +uint64_t WebExtensionPolicy::GetBrowsingContextGroupId(ErrorResult& aRv) { + if (XRE_IsParentProcess() && mActive) { + return GetBrowsingContextGroupId(); + } + aRv.ThrowInvalidAccessError( + "browsingContextGroupId only available for active policies in the " + "parent process"); + return 0; +} + +WebExtensionPolicy::~WebExtensionPolicy() { mCore->ClearPolicyWeakRef(); } + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(WebExtensionPolicy) +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(WebExtensionPolicy) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mParent) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mBrowsingContextGroup) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mLocalizeCallback) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mHostPermissions) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mContentScripts) + NS_IMPL_CYCLE_COLLECTION_UNLINK_PRESERVED_WRAPPER + AssertIsOnMainThread(); + tmp->mCore->ClearPolicyWeakRef(); +NS_IMPL_CYCLE_COLLECTION_UNLINK_END +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(WebExtensionPolicy) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mParent) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mBrowsingContextGroup) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mLocalizeCallback) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mHostPermissions) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mContentScripts) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(WebExtensionPolicy) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(WebExtensionPolicy) +NS_IMPL_CYCLE_COLLECTING_RELEASE(WebExtensionPolicy) + +/***************************************************************************** + * WebExtensionContentScript / MozDocumentMatcher + *****************************************************************************/ + +/* static */ +already_AddRefed MozDocumentMatcher::Constructor( + GlobalObject& aGlobal, const dom::MozDocumentMatcherInit& aInit, + ErrorResult& aRv) { + RefPtr matcher = + new MozDocumentMatcher(aGlobal, aInit, false, aRv); + if (aRv.Failed()) { + return nullptr; + } + return matcher.forget(); +} + +/* static */ +already_AddRefed +WebExtensionContentScript::Constructor(GlobalObject& aGlobal, + WebExtensionPolicy& aExtension, + const ContentScriptInit& aInit, + ErrorResult& aRv) { + RefPtr script = + new WebExtensionContentScript(aGlobal, aExtension, aInit, aRv); + if (aRv.Failed()) { + return nullptr; + } + return script.forget(); +} + +MozDocumentMatcher::MozDocumentMatcher(GlobalObject& aGlobal, + const dom::MozDocumentMatcherInit& aInit, + bool aRestricted, ErrorResult& aRv) + : mHasActiveTabPermission(aInit.mHasActiveTabPermission), + mRestricted(aRestricted), + mAllFrames(aInit.mAllFrames), + mCheckPermissions(aInit.mCheckPermissions), + mFrameID(aInit.mFrameID), + mMatchAboutBlank(aInit.mMatchAboutBlank) { + MatchPatternOptions options; + options.mRestrictSchemes = mRestricted; + + mMatches = ParseMatches(aGlobal, aInit.mMatches, options, + ErrorBehavior::CreateEmptyPattern, aRv); + if (aRv.Failed()) { + return; + } + + if (!aInit.mExcludeMatches.IsNull()) { + mExcludeMatches = + ParseMatches(aGlobal, aInit.mExcludeMatches.Value(), options, + ErrorBehavior::CreateEmptyPattern, aRv); + if (aRv.Failed()) { + return; + } + } + + if (!aInit.mIncludeGlobs.IsNull()) { + if (!ParseGlobs(aGlobal, aInit.mIncludeGlobs.Value(), + mIncludeGlobs.SetValue(), aRv)) { + return; + } + } + + if (!aInit.mExcludeGlobs.IsNull()) { + if (!ParseGlobs(aGlobal, aInit.mExcludeGlobs.Value(), + mExcludeGlobs.SetValue(), aRv)) { + return; + } + } + + if (!aInit.mOriginAttributesPatterns.IsNull()) { + Sequence& arr = + mOriginAttributesPatterns.SetValue(); + for (const auto& pattern : aInit.mOriginAttributesPatterns.Value()) { + if (!arr.AppendElement(OriginAttributesPattern(pattern), fallible)) { + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + return; + } + } + } +} + +WebExtensionContentScript::WebExtensionContentScript( + GlobalObject& aGlobal, WebExtensionPolicy& aExtension, + const ContentScriptInit& aInit, ErrorResult& aRv) + : MozDocumentMatcher(aGlobal, aInit, + !aExtension.HasPermission(nsGkAtoms::mozillaAddons), + aRv), + mRunAt(aInit.mRunAt) { + mCssPaths.Assign(aInit.mCssPaths); + mJsPaths.Assign(aInit.mJsPaths); + mExtension = &aExtension; + + // Origin permissions are optional in mv3, so always check them at runtime. + if (mExtension->ManifestVersion() >= 3) { + mCheckPermissions = true; + } +} + +bool MozDocumentMatcher::Matches(const DocInfo& aDoc, + bool aIgnorePermissions) const { + if (!mFrameID.IsNull()) { + if (aDoc.FrameID() != mFrameID.Value()) { + return false; + } + } else { + if (!mAllFrames && !aDoc.IsTopLevel()) { + return false; + } + } + + // match browsing mode with policy + nsCOMPtr loadContext = aDoc.GetLoadContext(); + if (loadContext && mExtension && !mExtension->CanAccessContext(loadContext)) { + return false; + } + + if (loadContext && !mOriginAttributesPatterns.IsNull()) { + OriginAttributes docShellAttrs; + loadContext->GetOriginAttributes(docShellAttrs); + bool patternMatch = false; + for (const auto& pattern : mOriginAttributesPatterns.Value()) { + if (pattern.Matches(docShellAttrs)) { + patternMatch = true; + break; + } + } + if (!patternMatch) { + return false; + } + } + + // TODO bug 1411641: we should account for precursorPrincipal if + // match_origin_as_fallback is specified (see also bug 1853411). + if (!mMatchAboutBlank && aDoc.URL().InheritsPrincipal()) { + return false; + } + + // Top-level about:blank is a special case. Unlike about:blank frames/windows + // opened by web pages, these do not have an origin that could be matched by + // a match pattern (they have a null principal instead). To allow extensions + // that intend to run scripts "everywhere", consider the document matched if + // the match pattern describe a very broad pattern (such as ""). + if (mMatchAboutBlank && aDoc.IsTopLevel() && + (aDoc.URL().Spec().EqualsLiteral("about:blank") || + aDoc.URL().Scheme() == nsGkAtoms::data) && + aDoc.Principal() && aDoc.Principal()->GetIsNullPrincipal()) { + if (StaticPrefs::extensions_script_about_blank_without_permission()) { + return true; + } + if (mHasActiveTabPermission) { + return true; + } + if (mMatches->MatchesAllWebUrls() && mIncludeGlobs.IsNull()) { + // When mIncludeGlobs is present, mMatches does not necessarily match + // everything (except possibly if include_globs is just ["*"]). So we + // only match if mMatches is present without mIncludeGlobs. + return true; + } + // Null principal is never going to match, so we may as well return now. + return false; + } + + if (mRestricted && WebExtensionPolicy::IsRestrictedDoc(aDoc)) { + return false; + } + + if (mRestricted && mExtension && mExtension->QuarantinedFromDoc(aDoc)) { + return false; + } + + auto& urlinfo = aDoc.PrincipalURL(); + if (mExtension && mExtension->ManifestVersion() >= 3) { + // In MV3, activeTab only allows access to same-origin iframes. + if (mHasActiveTabPermission && aDoc.IsSameOriginWithTop() && + MatchPattern::MatchesAllURLs(urlinfo)) { + return true; + } + } else { + if (mHasActiveTabPermission && aDoc.ShouldMatchActiveTabPermission() && + MatchPattern::MatchesAllURLs(urlinfo)) { + return true; + } + } + + return MatchesURI(urlinfo, aIgnorePermissions); +} + +bool MozDocumentMatcher::MatchesURI(const URLInfo& aURL, + bool aIgnorePermissions) const { + MOZ_ASSERT((!mRestricted && !mCheckPermissions) || mExtension); + + if (!mMatches->Matches(aURL)) { + return false; + } + + if (mExcludeMatches && mExcludeMatches->Matches(aURL)) { + return false; + } + + if (!mIncludeGlobs.IsNull() && !mIncludeGlobs.Value().Matches(aURL.CSpec())) { + return false; + } + + if (!mExcludeGlobs.IsNull() && mExcludeGlobs.Value().Matches(aURL.CSpec())) { + return false; + } + + if (mRestricted && WebExtensionPolicy::IsRestrictedURI(aURL)) { + return false; + } + + if (mRestricted && mExtension->QuarantinedFromURI(aURL)) { + return false; + } + + if (mCheckPermissions && !aIgnorePermissions && + !mExtension->CanAccessURI(aURL, false, false, true)) { + return false; + } + + return true; +} + +bool MozDocumentMatcher::MatchesWindowGlobal(WindowGlobalChild& aWindow, + bool aIgnorePermissions) const { + if (aWindow.IsClosed() || !aWindow.IsCurrentGlobal()) { + return false; + } + nsGlobalWindowInner* inner = aWindow.GetWindowGlobal(); + if (!inner || !inner->GetDocShell()) { + return false; + } + return Matches(inner->GetOuterWindow(), aIgnorePermissions); +} + +void MozDocumentMatcher::GetOriginAttributesPatterns( + JSContext* aCx, JS::MutableHandle aVal, + ErrorResult& aError) const { + if (!ToJSValue(aCx, mOriginAttributesPatterns, aVal)) { + aError.NoteJSContextException(aCx); + } +} + +JSObject* MozDocumentMatcher::WrapObject(JSContext* aCx, + JS::Handle aGivenProto) { + return MozDocumentMatcher_Binding::Wrap(aCx, this, aGivenProto); +} + +JSObject* WebExtensionContentScript::WrapObject( + JSContext* aCx, JS::Handle aGivenProto) { + return WebExtensionContentScript_Binding::Wrap(aCx, this, aGivenProto); +} + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(MozDocumentMatcher, mMatches, + mExcludeMatches, mExtension) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(MozDocumentMatcher) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(MozDocumentMatcher) +NS_IMPL_CYCLE_COLLECTING_RELEASE(MozDocumentMatcher) + +/***************************************************************************** + * MozDocumentObserver + *****************************************************************************/ + +/* static */ +already_AddRefed DocumentObserver::Constructor( + GlobalObject& aGlobal, dom::MozDocumentCallback& aCallbacks) { + RefPtr matcher = + new DocumentObserver(aGlobal.GetAsSupports(), aCallbacks); + return matcher.forget(); +} + +void DocumentObserver::Observe( + const dom::Sequence>& matchers, + ErrorResult& aRv) { + if (!EPS().RegisterObserver(*this)) { + aRv.Throw(NS_ERROR_FAILURE); + return; + } + mMatchers.Clear(); + for (auto& matcher : matchers) { + if (!mMatchers.AppendElement(matcher, fallible)) { + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + return; + } + } +} + +void DocumentObserver::Disconnect() { + Unused << EPS().UnregisterObserver(*this); +} + +void DocumentObserver::NotifyMatch(MozDocumentMatcher& aMatcher, + nsPIDOMWindowOuter* aWindow) { + IgnoredErrorResult rv; + mCallbacks->OnNewDocument( + aMatcher, WindowProxyHolder(aWindow->GetBrowsingContext()), rv); +} + +void DocumentObserver::NotifyMatch(MozDocumentMatcher& aMatcher, + nsILoadInfo* aLoadInfo) { + IgnoredErrorResult rv; + mCallbacks->OnPreloadDocument(aMatcher, aLoadInfo, rv); +} + +JSObject* DocumentObserver::WrapObject(JSContext* aCx, + JS::Handle aGivenProto) { + return MozDocumentObserver_Binding::Wrap(aCx, this, aGivenProto); +} + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(DocumentObserver, mCallbacks, mMatchers, + mParent) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(DocumentObserver) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(DocumentObserver) +NS_IMPL_CYCLE_COLLECTING_RELEASE(DocumentObserver) + +/***************************************************************************** + * DocInfo + *****************************************************************************/ + +DocInfo::DocInfo(const URLInfo& aURL, nsILoadInfo* aLoadInfo) + : mURL(aURL), mObj(AsVariant(aLoadInfo)) {} + +DocInfo::DocInfo(nsPIDOMWindowOuter* aWindow) + : mURL(aWindow->GetDocumentURI()), mObj(AsVariant(aWindow)) {} + +bool DocInfo::IsTopLevel() const { + if (mIsTopLevel.isNothing()) { + struct Matcher { + bool operator()(Window aWin) { + return aWin->GetBrowsingContext()->IsTop(); + } + bool operator()(LoadInfo aLoadInfo) { + return aLoadInfo->GetIsTopLevelLoad(); + } + }; + mIsTopLevel.emplace(mObj.match(Matcher())); + } + return mIsTopLevel.ref(); +} + +bool WindowShouldMatchActiveTab(nsPIDOMWindowOuter* aWin) { + WindowContext* wc = aWin->GetCurrentInnerWindow()->GetWindowContext(); + if (wc && wc->SameOriginWithTop()) { + // If the frame is same-origin to top, accept the match regardless of + // whether the frame was populated dynamically. + return true; + } + for (; wc; wc = wc->GetParentWindowContext()) { + BrowsingContext* bc = wc->GetBrowsingContext(); + if (bc->IsTopContent()) { + return true; + } + + if (bc->CreatedDynamically() || !wc->GetIsOriginalFrameSource()) { + return false; + } + } + MOZ_ASSERT_UNREACHABLE("Should reach top content before end of loop"); + return false; +} + +bool DocInfo::ShouldMatchActiveTabPermission() const { + struct Matcher { + bool operator()(Window aWin) { return WindowShouldMatchActiveTab(aWin); } + bool operator()(LoadInfo aLoadInfo) { return false; } + }; + return mObj.match(Matcher()); +} + +bool DocInfo::IsSameOriginWithTop() const { + struct Matcher { + bool operator()(Window aWin) { + WindowContext* wc = aWin->GetCurrentInnerWindow()->GetWindowContext(); + return wc && wc->SameOriginWithTop(); + } + bool operator()(LoadInfo aLoadInfo) { return false; } + }; + return mObj.match(Matcher()); +} + +uint64_t DocInfo::FrameID() const { + if (mFrameID.isNothing()) { + if (IsTopLevel()) { + mFrameID.emplace(0); + } else { + struct Matcher { + uint64_t operator()(Window aWin) { + return aWin->GetBrowsingContext()->Id(); + } + uint64_t operator()(LoadInfo aLoadInfo) { + return aLoadInfo->GetBrowsingContextID(); + } + }; + mFrameID.emplace(mObj.match(Matcher())); + } + } + return mFrameID.ref(); +} + +nsIPrincipal* DocInfo::Principal() const { + if (mPrincipal.isNothing()) { + struct Matcher { + explicit Matcher(const DocInfo& aThis) : mThis(aThis) {} + const DocInfo& mThis; + + nsIPrincipal* operator()(Window aWin) { + RefPtr doc = aWin->GetDoc(); + return doc->NodePrincipal(); + } + nsIPrincipal* operator()(LoadInfo aLoadInfo) { + if (!(mThis.URL().InheritsPrincipal() || + aLoadInfo->GetForceInheritPrincipal())) { + return nullptr; + } + if (auto principal = aLoadInfo->PrincipalToInherit()) { + return principal; + } + return aLoadInfo->TriggeringPrincipal(); + } + }; + mPrincipal.emplace(mObj.match(Matcher(*this))); + } + return mPrincipal.ref(); +} + +const URLInfo& DocInfo::PrincipalURL() const { + if (!(Principal() && Principal()->GetIsContentPrincipal())) { + return URL(); + } + + if (mPrincipalURL.isNothing()) { + nsIPrincipal* prin = Principal(); + auto* basePrin = BasePrincipal::Cast(prin); + nsCOMPtr uri; + if (NS_SUCCEEDED(basePrin->GetURI(getter_AddRefs(uri)))) { + MOZ_DIAGNOSTIC_ASSERT(uri); + mPrincipalURL.emplace(uri); + } else { + mPrincipalURL.emplace(URL()); + } + } + + return mPrincipalURL.ref(); +} + +} // namespace extensions +} // namespace mozilla diff --git a/toolkit/components/extensions/WebExtensionPolicy.h b/toolkit/components/extensions/WebExtensionPolicy.h new file mode 100644 index 0000000000..e5824f8061 --- /dev/null +++ b/toolkit/components/extensions/WebExtensionPolicy.h @@ -0,0 +1,420 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */ +/* 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/. */ + +#ifndef mozilla_extensions_WebExtensionPolicy_h +#define mozilla_extensions_WebExtensionPolicy_h + +#include "MainThreadUtils.h" +#include "mozilla/RWLock.h" +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/dom/BrowsingContextGroup.h" +#include "mozilla/dom/Nullable.h" +#include "mozilla/dom/WebExtensionPolicyBinding.h" +#include "mozilla/dom/WindowProxyHolder.h" +#include "mozilla/extensions/MatchPattern.h" + +#include "jspubtd.h" + +#include "mozilla/Result.h" +#include "mozilla/WeakPtr.h" +#include "nsCOMPtr.h" +#include "nsCycleCollectionParticipant.h" +#include "nsGkAtoms.h" +#include "nsISupports.h" +#include "nsWrapperCache.h" + +namespace mozilla { +namespace dom { +class Promise; +} // namespace dom + +namespace extensions { + +using dom::WebAccessibleResourceInit; +using dom::WebExtensionInit; +using dom::WebExtensionLocalizeCallback; + +class DocInfo; +class WebExtensionContentScript; + +class WebAccessibleResource final { + public: + NS_INLINE_DECL_THREADSAFE_REFCOUNTING(WebAccessibleResource) + + WebAccessibleResource(dom::GlobalObject& aGlobal, + const WebAccessibleResourceInit& aInit, + ErrorResult& aRv); + + bool IsWebAccessiblePath(const nsACString& aPath) const { + return mWebAccessiblePaths.Matches(aPath); + } + + bool SourceMayAccessPath(const URLInfo& aURI, const nsACString& aPath) { + return mWebAccessiblePaths.Matches(aPath) && + (IsHostMatch(aURI) || IsExtensionMatch(aURI)); + } + + bool IsHostMatch(const URLInfo& aURI) { + return mMatches && mMatches->Matches(aURI); + } + + bool IsExtensionMatch(const URLInfo& aURI); + + private: + ~WebAccessibleResource() = default; + + MatchGlobSet mWebAccessiblePaths; + RefPtr mMatches; + RefPtr mExtensionIDs; +}; + +/// The thread-safe component of the WebExtensionPolicy. +/// +/// Acts as a weak reference to the base WebExtensionPolicy. +class WebExtensionPolicyCore final { + public: + NS_INLINE_DECL_THREADSAFE_REFCOUNTING(WebExtensionPolicyCore) + + nsAtom* Id() const { return mId; } + + const nsCString& MozExtensionHostname() const { return mHostname; } + + nsIURI* BaseURI() const { return mBaseURI; } + + bool IsPrivileged() { return mIsPrivileged; } + + bool TemporarilyInstalled() { return mTemporarilyInstalled; } + + const nsString& Name() const { return mName; } + + nsAtom* Type() const { return mType; } + + uint32_t ManifestVersion() const { return mManifestVersion; } + + const nsString& ExtensionPageCSP() const { return mExtensionPageCSP; } + + const nsString& BaseCSP() const { return mBaseCSP; } + + const nsString& BackgroundWorkerScript() const { + return mBackgroundWorkerScript; + } + + bool IsWebAccessiblePath(const nsACString& aPath) const { + for (const auto& resource : mWebAccessibleResources) { + if (resource->IsWebAccessiblePath(aPath)) { + return true; + } + } + return false; + } + + bool SourceMayAccessPath(const URLInfo& aURI, const nsACString& aPath) const; + + bool HasPermission(const nsAtom* aPermission) const { + AutoReadLock lock(mLock); + return mPermissions->Contains(aPermission); + } + + void GetPermissions(nsTArray& aResult) const MOZ_EXCLUDES(mLock) { + AutoReadLock lock(mLock); + return mPermissions->Get(aResult); + } + + void SetPermissions(const nsTArray& aPermissions) + MOZ_EXCLUDES(mLock) { + RefPtr newPermissions = new AtomSet(aPermissions); + AutoWriteLock lock(mLock); + mPermissions = std::move(newPermissions); + } + + bool CanAccessURI(const URLInfo& aURI, bool aExplicit = false, + bool aCheckRestricted = true, + bool aAllowFilePermission = false) const; + + bool IgnoreQuarantine() const MOZ_EXCLUDES(mLock) { + AutoReadLock lock(mLock); + return mIgnoreQuarantine; + } + void SetIgnoreQuarantine(bool aIgnore) MOZ_EXCLUDES(mLock) { + AutoWriteLock lock(mLock); + mIgnoreQuarantine = aIgnore; + } + + bool QuarantinedFromDoc(const DocInfo& aDoc) const; + bool QuarantinedFromURI(const URLInfo& aURI) const MOZ_EXCLUDES(mLock); + + // Try to get a reference to the cycle-collected main-thread-only + // WebExtensionPolicy instance. + // + // Will return nullptr if the policy has already been unlinked or destroyed. + WebExtensionPolicy* GetMainThreadPolicy() const + MOZ_REQUIRES(sMainThreadCapability) { + return mPolicy; + } + + private: + friend class WebExtensionPolicy; + + WebExtensionPolicyCore(dom::GlobalObject& aGlobal, + WebExtensionPolicy* aPolicy, + const WebExtensionInit& aInit, ErrorResult& aRv); + + ~WebExtensionPolicyCore() = default; + + void ClearPolicyWeakRef() MOZ_REQUIRES(sMainThreadCapability) { + mPolicy = nullptr; + } + + // Unless otherwise guarded by a capability, all members on + // WebExtensionPolicyCore should be immutable and threadsafe. + + WebExtensionPolicy* MOZ_NON_OWNING_REF mPolicy + MOZ_GUARDED_BY(sMainThreadCapability); + + const RefPtr mId; + /* const */ nsCString mHostname; + /* const */ nsCOMPtr mBaseURI; + + const nsString mName; + const RefPtr mType; + const uint32_t mManifestVersion; + /* const */ nsString mExtensionPageCSP; + /* const */ nsString mBaseCSP; + + const bool mIsPrivileged; + const bool mTemporarilyInstalled; + + const nsString mBackgroundWorkerScript; + + /* const */ nsTArray> mWebAccessibleResources; + + mutable RWLock mLock{"WebExtensionPolicyCore"}; + + bool mIgnoreQuarantine MOZ_GUARDED_BY(mLock); + RefPtr mPermissions MOZ_GUARDED_BY(mLock); + RefPtr mHostPermissions MOZ_GUARDED_BY(mLock); +}; + +class WebExtensionPolicy final : public nsISupports, public nsWrapperCache { + public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(WebExtensionPolicy) + + using ScriptArray = nsTArray>; + + static already_AddRefed Constructor( + dom::GlobalObject& aGlobal, const WebExtensionInit& aInit, + ErrorResult& aRv); + + WebExtensionPolicyCore* Core() const { return mCore; } + + nsAtom* Id() const { return mCore->Id(); } + void GetId(nsAString& aId) const { aId = nsDependentAtomString(Id()); }; + + const nsCString& MozExtensionHostname() const { + return mCore->MozExtensionHostname(); + } + void GetMozExtensionHostname(nsACString& aHostname) const { + aHostname = MozExtensionHostname(); + } + + nsIURI* BaseURI() const { return mCore->BaseURI(); } + void GetBaseURL(nsACString& aBaseURL) const { + MOZ_ALWAYS_SUCCEEDS(mCore->BaseURI()->GetSpec(aBaseURL)); + } + + bool IsPrivileged() { return mCore->IsPrivileged(); } + + bool TemporarilyInstalled() { return mCore->TemporarilyInstalled(); } + + void GetURL(const nsAString& aPath, nsAString& aURL, ErrorResult& aRv) const; + + Result GetURL(const nsAString& aPath) const; + + void RegisterContentScript(WebExtensionContentScript& script, + ErrorResult& aRv); + + void UnregisterContentScript(const WebExtensionContentScript& script, + ErrorResult& aRv); + + void InjectContentScripts(ErrorResult& aRv); + + bool CanAccessURI(const URLInfo& aURI, bool aExplicit = false, + bool aCheckRestricted = true, + bool aAllowFilePermission = false) const { + return mCore->CanAccessURI(aURI, aExplicit, aCheckRestricted, + aAllowFilePermission); + } + + bool IsWebAccessiblePath(const nsACString& aPath) const { + return mCore->IsWebAccessiblePath(aPath); + } + + bool SourceMayAccessPath(const URLInfo& aURI, const nsACString& aPath) const { + return mCore->SourceMayAccessPath(aURI, aPath); + } + + bool HasPermission(const nsAtom* aPermission) const { + return mCore->HasPermission(aPermission); + } + bool HasPermission(const nsAString& aPermission) const { + RefPtr atom = NS_AtomizeMainThread(aPermission); + return HasPermission(atom); + } + + static bool IsRestrictedDoc(const DocInfo& aDoc); + static bool IsRestrictedURI(const URLInfo& aURI); + + static bool IsQuarantinedDoc(const DocInfo& aDoc); + static bool IsQuarantinedURI(const URLInfo& aURI); + + bool QuarantinedFromDoc(const DocInfo& aDoc) const { + return mCore->QuarantinedFromDoc(aDoc); + } + + bool QuarantinedFromURI(const URLInfo& aURI) const { + return mCore->QuarantinedFromURI(aURI); + } + + nsCString BackgroundPageHTML() const; + + MOZ_CAN_RUN_SCRIPT + void Localize(const nsAString& aInput, nsString& aResult) const; + + const nsString& Name() const { return mCore->Name(); } + void GetName(nsAString& aName) const { aName = Name(); } + + nsAtom* Type() const { return mCore->Type(); } + void GetType(nsAString& aType) const { + aType = nsDependentAtomString(Type()); + }; + + uint32_t ManifestVersion() const { return mCore->ManifestVersion(); } + + const nsString& ExtensionPageCSP() const { return mCore->ExtensionPageCSP(); } + void GetExtensionPageCSP(nsAString& aCSP) const { aCSP = ExtensionPageCSP(); } + + const nsString& BaseCSP() const { return mCore->BaseCSP(); } + void GetBaseCSP(nsAString& aCSP) const { aCSP = BaseCSP(); } + + already_AddRefed AllowedOrigins() { + return do_AddRef(mHostPermissions); + } + void SetAllowedOrigins(MatchPatternSet& aAllowedOrigins); + + void GetPermissions(nsTArray& aResult) const { + mCore->GetPermissions(aResult); + } + void SetPermissions(const nsTArray& aPermissions) { + mCore->SetPermissions(aPermissions); + } + + bool IgnoreQuarantine() const { return mCore->IgnoreQuarantine(); } + void SetIgnoreQuarantine(bool aIgnore); + + void GetContentScripts(ScriptArray& aScripts) const; + const ScriptArray& ContentScripts() const { return mContentScripts; } + + bool Active() const { return mActive; } + void SetActive(bool aActive, ErrorResult& aRv); + + bool PrivateBrowsingAllowed() const; + + bool CanAccessContext(nsILoadContext* aContext) const; + + bool CanAccessWindow(const dom::WindowProxyHolder& aWindow) const; + + void GetReadyPromise(JSContext* aCx, + JS::MutableHandle aResult) const; + dom::Promise* ReadyPromise() const { return mReadyPromise; } + + const nsString& BackgroundWorkerScript() const { + return mCore->BackgroundWorkerScript(); + } + void GetBackgroundWorker(nsString& aScriptURL) const { + aScriptURL.Assign(BackgroundWorkerScript()); + } + + bool IsManifestBackgroundWorker(const nsAString& aWorkerScriptURL) const { + return BackgroundWorkerScript().Equals(aWorkerScriptURL); + } + + uint64_t GetBrowsingContextGroupId() const; + uint64_t GetBrowsingContextGroupId(ErrorResult& aRv); + + static void GetActiveExtensions( + dom::GlobalObject& aGlobal, + nsTArray>& aResults); + + static already_AddRefed GetByID( + dom::GlobalObject& aGlobal, const nsAString& aID); + + static already_AddRefed GetByHostname( + dom::GlobalObject& aGlobal, const nsACString& aHostname); + + static already_AddRefed GetByURI( + dom::GlobalObject& aGlobal, nsIURI* aURI); + + static bool IsRestrictedURI(dom::GlobalObject& aGlobal, const URLInfo& aURI) { + return IsRestrictedURI(aURI); + } + + static bool IsQuarantinedURI(dom::GlobalObject& aGlobal, + const URLInfo& aURI) { + return IsQuarantinedURI(aURI); + } + + bool QuarantinedFromURI(dom::GlobalObject& aGlobal, + const URLInfo& aURI) const { + return QuarantinedFromURI(aURI); + } + + static bool UseRemoteWebExtensions(dom::GlobalObject& aGlobal); + static bool IsExtensionProcess(dom::GlobalObject& aGlobal); + static bool BackgroundServiceWorkerEnabled(dom::GlobalObject& aGlobal); + static bool QuarantinedDomainsEnabled(dom::GlobalObject& aGlobal); + + nsISupports* GetParentObject() const { return mParent; } + + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle aGivenProto) override; + + protected: + ~WebExtensionPolicy(); + + private: + WebExtensionPolicy(dom::GlobalObject& aGlobal, const WebExtensionInit& aInit, + ErrorResult& aRv); + + bool Enable(); + bool Disable(); + + nsCOMPtr mParent; + + RefPtr mCore; + + dom::BrowsingContextGroup::KeepAlivePtr mBrowsingContextGroup; + + bool mActive = false; + + RefPtr mLocalizeCallback; + + // NOTE: This is a mirror of the object in `mCore`, except with the + // non-threadsafe wrapper. + RefPtr mHostPermissions; + + dom::Nullable> mBackgroundScripts; + + bool mBackgroundTypeModule = false; + + nsTArray> mContentScripts; + + RefPtr mReadyPromise; +}; + +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_WebExtensionPolicy_h diff --git a/toolkit/components/extensions/WebNavigation.sys.mjs b/toolkit/components/extensions/WebNavigation.sys.mjs new file mode 100644 index 0000000000..3de3c58986 --- /dev/null +++ b/toolkit/components/extensions/WebNavigation.sys.mjs @@ -0,0 +1,400 @@ +/* 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/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", + ClickHandlerParent: "resource:///actors/ClickHandlerParent.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", + WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.sys.mjs", +}); + +// Maximum amount of time that can be passed and still consider +// the data recent (similar to how is done in nsNavHistory, +// e.g. nsNavHistory::CheckIsRecentEvent, but with a lower threshold value). +const RECENT_DATA_THRESHOLD = 5 * 1000000; + +function getBrowser(bc) { + return bc.top.embedderElement; +} + +export var WebNavigationManager = { + // Map[string -> Map[listener -> URLFilter]] + listeners: new Map(), + + init() { + // Collect recent tab transition data in a WeakMap: + // browser -> tabTransitionData + this.recentTabTransitionData = new WeakMap(); + + Services.obs.addObserver(this, "urlbar-user-start-navigation", true); + + Services.obs.addObserver(this, "webNavigation-createdNavigationTarget"); + + if (AppConstants.MOZ_BUILD_APP == "browser") { + lazy.ClickHandlerParent.addContentClickListener(this); + } + }, + + uninit() { + // Stop collecting recent tab transition data and reset the WeakMap. + Services.obs.removeObserver(this, "urlbar-user-start-navigation"); + Services.obs.removeObserver(this, "webNavigation-createdNavigationTarget"); + + if (AppConstants.MOZ_BUILD_APP == "browser") { + lazy.ClickHandlerParent.removeContentClickListener(this); + } + + this.recentTabTransitionData = new WeakMap(); + }, + + addListener(type, listener) { + if (this.listeners.size == 0) { + this.init(); + } + + if (!this.listeners.has(type)) { + this.listeners.set(type, new Set()); + } + let listeners = this.listeners.get(type); + listeners.add(listener); + }, + + removeListener(type, listener) { + let listeners = this.listeners.get(type); + if (!listeners) { + return; + } + listeners.delete(listener); + if (listeners.size == 0) { + this.listeners.delete(type); + } + + if (this.listeners.size == 0) { + this.uninit(); + } + }, + + /** + * Support nsIObserver interface to observe the urlbar autocomplete events used + * to keep track of the urlbar user interaction. + */ + QueryInterface: ChromeUtils.generateQI([ + "extIWebNavigation", + "nsIObserver", + "nsISupportsWeakReference", + ]), + + /** + * Observe webNavigation-createdNavigationTarget (to fire the onCreatedNavigationTarget + * related to windows or tabs opened from the main process) topics. + * + * @param {nsIAutoCompleteInput | object} subject + * @param {string} topic + * @param {string | undefined} data + */ + observe: function (subject, topic, data) { + if (topic == "urlbar-user-start-navigation") { + this.onURLBarUserStartNavigation(subject.wrappedJSObject); + } else if (topic == "webNavigation-createdNavigationTarget") { + // The observed notification is coming from privileged JavaScript components running + // in the main process (e.g. when a new tab or window is opened using the context menu + // or Ctrl/Shift + click on a link). + const { createdTabBrowser, url, sourceFrameID, sourceTabBrowser } = + subject.wrappedJSObject; + + this.fire("onCreatedNavigationTarget", createdTabBrowser, null, { + sourceTabBrowser, + sourceFrameId: sourceFrameID, + url, + }); + } + }, + + /** + * Recognize the type of urlbar user interaction (e.g. typing a new url, + * clicking on an url generated from a searchengine or a keyword, or a + * bookmark found by the urlbar autocompletion). + * + * @param {object} acData + * The data for the autocompleted item. + * @param {object} [acData.result] + * The result information associated with the navigation action. + * @param {UrlbarUtils.RESULT_TYPE} [acData.result.type] + * The result type associated with the navigation action. + * @param {UrlbarUtils.RESULT_SOURCE} [acData.result.source] + * The result source associated with the navigation action. + */ + onURLBarUserStartNavigation(acData) { + let tabTransitionData = { + from_address_bar: true, + }; + + if (!acData.result) { + tabTransitionData.typed = true; + } else { + switch (acData.result.type) { + case lazy.UrlbarUtils.RESULT_TYPE.KEYWORD: + tabTransitionData.keyword = true; + break; + case lazy.UrlbarUtils.RESULT_TYPE.SEARCH: + tabTransitionData.generated = true; + break; + case lazy.UrlbarUtils.RESULT_TYPE.URL: + if ( + acData.result.source == lazy.UrlbarUtils.RESULT_SOURCE.BOOKMARKS + ) { + tabTransitionData.auto_bookmark = true; + } else { + tabTransitionData.typed = true; + } + break; + case lazy.UrlbarUtils.RESULT_TYPE.REMOTE_TAB: + // Remote tab are autocomplete results related to + // tab urls from a remote synchronized Firefox. + tabTransitionData.typed = true; + break; + case lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH: + // This "switchtab" autocompletion should be ignored, because + // it is not related to a navigation. + // Fall through. + case lazy.UrlbarUtils.RESULT_TYPE.OMNIBOX: + // "Omnibox" should be ignored as the add-on may or may not initiate + // a navigation on the item being selected. + // Fall through. + case lazy.UrlbarUtils.RESULT_TYPE.TIP: + // "Tip" should be ignored since the tip will only initiate navigation + // if there is a valid buttonUrl property, which is optional. + throw new Error( + `Unexpectedly received notification for ${acData.result.type}` + ); + default: + Cu.reportError( + `Received unexpected result type ${acData.result.type}, falling back to typed transition.` + ); + // Fallback on "typed" if the type is unknown. + tabTransitionData.typed = true; + } + } + + this.setRecentTabTransitionData(tabTransitionData); + }, + + /** + * Keep track of a recent user interaction and cache it in a + * map associated to the current selected tab. + * + * @param {object} tabTransitionData + * @param {boolean} [tabTransitionData.auto_bookmark] + * @param {boolean} [tabTransitionData.from_address_bar] + * @param {boolean} [tabTransitionData.generated] + * @param {boolean} [tabTransitionData.keyword] + * @param {boolean} [tabTransitionData.link] + * @param {boolean} [tabTransitionData.typed] + */ + setRecentTabTransitionData(tabTransitionData) { + let window = lazy.BrowserWindowTracker.getTopWindow(); + if ( + window && + window.gBrowser && + window.gBrowser.selectedTab && + window.gBrowser.selectedTab.linkedBrowser + ) { + let browser = window.gBrowser.selectedTab.linkedBrowser; + + // Get recent tab transition data to update if any. + let prevData = this.getAndForgetRecentTabTransitionData(browser); + + let newData = Object.assign( + { time: Date.now() }, + prevData, + tabTransitionData + ); + this.recentTabTransitionData.set(browser, newData); + } + }, + + /** + * Retrieve recent data related to a recent user interaction give a + * given tab's linkedBrowser (only if is is more recent than the + * `RECENT_DATA_THRESHOLD`). + * + * NOTE: this method is used to retrieve the tab transition data + * collected when one of the `onCommitted`, `onHistoryStateUpdated` + * or `onReferenceFragmentUpdated` events has been received. + * + * @param {XULBrowserElement} browser + * @returns {object} + */ + getAndForgetRecentTabTransitionData(browser) { + let data = this.recentTabTransitionData.get(browser); + this.recentTabTransitionData.delete(browser); + + // Return an empty object if there isn't any tab transition data + // or if it's less recent than RECENT_DATA_THRESHOLD. + if (!data || data.time - Date.now() > RECENT_DATA_THRESHOLD) { + return {}; + } + + return data; + }, + + onContentClick(target, data) { + // We are interested only on clicks to links which are not "add to bookmark" commands + if (data.href && !data.bookmark) { + let ownerWin = target.ownerGlobal; + let where = ownerWin.whereToOpenLink(data); + if (where == "current") { + this.setRecentTabTransitionData({ link: true }); + } + } + }, + + onCreatedNavigationTarget(bc, sourceBC, url) { + if (!this.listeners.size) { + return; + } + + let browser = getBrowser(bc); + + this.fire("onCreatedNavigationTarget", browser, null, { + sourceTabBrowser: getBrowser(sourceBC), + sourceFrameId: lazy.WebNavigationFrames.getFrameId(sourceBC), + url, + }); + }, + + onStateChange(bc, requestURI, status, stateFlags) { + if (!this.listeners.size) { + return; + } + + let browser = getBrowser(bc); + + if (stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW) { + let url = requestURI.spec; + if (stateFlags & Ci.nsIWebProgressListener.STATE_START) { + this.fire("onBeforeNavigate", browser, bc, { url }); + } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + if (Components.isSuccessCode(status)) { + this.fire("onCompleted", browser, bc, { url }); + } else { + let error = `Error code ${status}`; + this.fire("onErrorOccurred", browser, bc, { error, url }); + } + } + } + }, + + onDocumentChange(bc, frameTransitionData, location) { + if (!this.listeners.size) { + return; + } + + let browser = getBrowser(bc); + + let extra = { + url: location ? location.spec : "", + // Transition data which is coming from the content process. + frameTransitionData, + tabTransitionData: this.getAndForgetRecentTabTransitionData(browser), + }; + + this.fire("onCommitted", browser, bc, extra); + }, + + onHistoryChange( + bc, + frameTransitionData, + location, + isHistoryStateUpdated, + isReferenceFragmentUpdated + ) { + if (!this.listeners.size) { + return; + } + + let browser = getBrowser(bc); + + let extra = { + url: location ? location.spec : "", + // Transition data which is coming from the content process. + frameTransitionData, + tabTransitionData: this.getAndForgetRecentTabTransitionData(browser), + }; + + if (isReferenceFragmentUpdated) { + this.fire("onReferenceFragmentUpdated", browser, bc, extra); + } else if (isHistoryStateUpdated) { + this.fire("onHistoryStateUpdated", browser, bc, extra); + } + }, + + onDOMContentLoaded(bc, documentURI) { + if (!this.listeners.size) { + return; + } + + let browser = getBrowser(bc); + + this.fire("onDOMContentLoaded", browser, bc, { url: documentURI.spec }); + }, + + fire(type, browser, bc, extra) { + if (!browser) { + return; + } + + let listeners = this.listeners.get(type); + if (!listeners) { + return; + } + + let details = { + browser, + }; + + if (bc) { + details.frameId = lazy.WebNavigationFrames.getFrameId(bc); + details.parentFrameId = lazy.WebNavigationFrames.getParentFrameId(bc); + } + + for (let prop in extra) { + details[prop] = extra[prop]; + } + + for (let listener of listeners) { + listener(details); + } + }, +}; + +const EVENTS = [ + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", + "onErrorOccurred", + "onReferenceFragmentUpdated", + "onHistoryStateUpdated", + "onCreatedNavigationTarget", +]; + +export var WebNavigation = {}; + +for (let event of EVENTS) { + WebNavigation[event] = { + addListener: WebNavigationManager.addListener.bind( + WebNavigationManager, + event + ), + removeListener: WebNavigationManager.removeListener.bind( + WebNavigationManager, + event + ), + }; +} diff --git a/toolkit/components/extensions/WebNavigationFrames.sys.mjs b/toolkit/components/extensions/WebNavigationFrames.sys.mjs new file mode 100644 index 0000000000..211698a88e --- /dev/null +++ b/toolkit/components/extensions/WebNavigationFrames.sys.mjs @@ -0,0 +1,90 @@ +/* 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/. */ + +/** + * The FrameDetail object which represents a frame in WebExtensions APIs. + * + * @typedef {object} FrameDetail + * @inner + * @property {number} frameId - Represents the numeric id which identify the frame in its tab. + * @property {number} parentFrameId - Represents the numeric id which identify the parent frame. + * @property {string} url - Represents the current location URL loaded in the frame. + * @property {boolean} errorOccurred - Indicates whether an error is occurred during the last load + * happened on this frame (NOT YET SUPPORTED). + */ + +/** + * Returns the frame ID of the given window. If the window is the + * top-level content window, its frame ID is 0. Otherwise, its frame ID + * is its outer window ID. + * + * @param {Window|BrowsingContext} bc - The window to retrieve the frame ID for. + * @returns {number} + */ +function getFrameId(bc) { + if (!BrowsingContext.isInstance(bc)) { + bc = bc.browsingContext; + } + return bc.parent ? bc.id : 0; +} + +/** + * Returns the frame ID of the given window's parent. + * + * @param {Window|BrowsingContext} bc - The window to retrieve the parent frame ID for. + * @returns {number} + */ +function getParentFrameId(bc) { + if (!BrowsingContext.isInstance(bc)) { + bc = bc.browsingContext; + } + return bc.parent ? getFrameId(bc.parent) : -1; +} + +/** + * Convert a BrowsingContext into internal FrameDetail json. + * + * @param {BrowsingContext} bc + * @returns {FrameDetail} + */ +function getFrameDetail(bc) { + return { + frameId: getFrameId(bc), + parentFrameId: getParentFrameId(bc), + url: bc.currentURI?.spec, + }; +} + +export var WebNavigationFrames = { + getFrame(bc, frameId) { + // frameId 0 means the top-level frame; anything else is a child frame. + let frame = BrowsingContext.get(frameId || bc.id); + if (frame && frame.top === bc) { + return getFrameDetail(frame); + } + return null; + }, + + getFrameId, + getParentFrameId, + + getAllFrames(bc) { + let frames = []; + + // Recursively walk the BC tree, find all frames. + function visit(bc) { + frames.push(bc); + bc.children.forEach(visit); + } + visit(bc); + return frames.map(getFrameDetail); + }, + + getFromWindow(target) { + if (Window.isInstance(target)) { + return getFrameId(BrowsingContext.getFromWindow(target)); + } + return -1; + }, +}; diff --git a/toolkit/components/extensions/child/.eslintrc.js b/toolkit/components/extensions/child/.eslintrc.js new file mode 100644 index 0000000000..01f6e45d35 --- /dev/null +++ b/toolkit/components/extensions/child/.eslintrc.js @@ -0,0 +1,11 @@ +/* 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"; + +module.exports = { + globals: { + EventManager: true, + }, +}; diff --git a/toolkit/components/extensions/child/ext-backgroundPage.js b/toolkit/components/extensions/child/ext-backgroundPage.js new file mode 100644 index 0000000000..ef5b3dd339 --- /dev/null +++ b/toolkit/components/extensions/child/ext-backgroundPage.js @@ -0,0 +1,40 @@ +/* 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"; + +this.backgroundPage = class extends ExtensionAPI { + getAPI(context) { + function getBackgroundPage() { + for (let view of context.extension.views) { + if ( + // To find the (top-level) background context, this logic relies on + // the order of views, implied by the fact that the top-level context + // is created before child contexts. If this assumption ever becomes + // invalid, add a check for view.isBackgroundContext. + view.viewType == "background" && + context.principal.subsumes(view.principal) + ) { + return view.contentWindow; + } + } + return null; + } + return { + extension: { + getBackgroundPage, + }, + + runtime: { + getBackgroundPage() { + return context.childManager + .callParentAsyncFunction("runtime.internalWakeupBackground", []) + .then(() => { + return context.cloneScope.Promise.resolve(getBackgroundPage()); + }); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-contentScripts.js b/toolkit/components/extensions/child/ext-contentScripts.js new file mode 100644 index 0000000000..338374cde6 --- /dev/null +++ b/toolkit/components/extensions/child/ext-contentScripts.js @@ -0,0 +1,76 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +var { ExtensionError } = ExtensionUtils; + +/** + * Represents (in the child extension process) a content script registered + * programmatically (instead of being included in the addon manifest). + * + * @param {ExtensionPageContextChild} context + * The extension context which has registered the content script. + * @param {string} scriptId + * An unique id that represents the registered content script + * (generated and used internally to identify it across the different processes). + */ +class ContentScriptChild { + constructor(context, scriptId) { + this.context = context; + this.scriptId = scriptId; + this.unregistered = false; + } + + async unregister() { + if (this.unregistered) { + throw new ExtensionError("Content script already unregistered"); + } + + this.unregistered = true; + + await this.context.childManager.callParentAsyncFunction( + "contentScripts.unregister", + [this.scriptId] + ); + + this.context = null; + } + + api() { + const { context } = this; + + // TODO(rpl): allow to read the options related to the registered content script? + return { + unregister: () => { + return context.wrapPromise(this.unregister()); + }, + }; + } +} + +this.contentScripts = class extends ExtensionAPI { + getAPI(context) { + return { + contentScripts: { + register(options) { + return context.cloneScope.Promise.resolve().then(async () => { + const scriptId = await context.childManager.callParentAsyncFunction( + "contentScripts.register", + [options] + ); + + const registeredScript = new ContentScriptChild(context, scriptId); + + return Cu.cloneInto(registeredScript.api(), context.cloneScope, { + cloneFunctions: true, + }); + }); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-declarativeNetRequest.js b/toolkit/components/extensions/child/ext-declarativeNetRequest.js new file mode 100644 index 0000000000..82028c6105 --- /dev/null +++ b/toolkit/components/extensions/child/ext-declarativeNetRequest.js @@ -0,0 +1,35 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionDNRLimits: "resource://gre/modules/ExtensionDNRLimits.sys.mjs", +}); + +this.declarativeNetRequest = class extends ExtensionAPI { + getAPI(context) { + return { + declarativeNetRequest: { + get GUARANTEED_MINIMUM_STATIC_RULES() { + return ExtensionDNRLimits.GUARANTEED_MINIMUM_STATIC_RULES; + }, + get MAX_NUMBER_OF_STATIC_RULESETS() { + return ExtensionDNRLimits.MAX_NUMBER_OF_STATIC_RULESETS; + }, + get MAX_NUMBER_OF_ENABLED_STATIC_RULESETS() { + return ExtensionDNRLimits.MAX_NUMBER_OF_ENABLED_STATIC_RULESETS; + }, + get MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES() { + return ExtensionDNRLimits.MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES; + }, + get MAX_NUMBER_OF_REGEX_RULES() { + return ExtensionDNRLimits.MAX_NUMBER_OF_REGEX_RULES; + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-extension.js b/toolkit/components/extensions/child/ext-extension.js new file mode 100644 index 0000000000..f4024086e4 --- /dev/null +++ b/toolkit/components/extensions/child/ext-extension.js @@ -0,0 +1,78 @@ +/* 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"; + +this.extension = class extends ExtensionAPI { + getAPI(context) { + let api = { + getURL(url) { + return context.extension.baseURI.resolve(url); + }, + + get lastError() { + return context.lastError; + }, + + get inIncognitoContext() { + return context.incognito; + }, + }; + + if (context.envType === "addon_child") { + api.getViews = function (fetchProperties) { + let result = Cu.cloneInto([], context.cloneScope); + + for (let view of context.extension.views) { + if (!view.active) { + continue; + } + if (!context.principal.subsumes(view.principal)) { + continue; + } + + if (fetchProperties !== null) { + if ( + fetchProperties.type !== null && + view.viewType != fetchProperties.type + ) { + continue; + } + + if (fetchProperties.windowId !== null) { + let bc = view.contentWindow?.docShell?.browserChild; + let windowId = + view.viewType !== "background" + ? bc?.chromeOuterWindowID ?? -1 + : -1; + if (windowId !== fetchProperties.windowId) { + continue; + } + } + + if ( + fetchProperties.tabId !== null && + view.tabId != fetchProperties.tabId + ) { + continue; + } + } + + // Do not include extension popups contexts while their document + // is blocked on parsing during its preloading state + // (See Bug 1748808). + if (context.extension.hasContextBlockedParsingDocument(view)) { + continue; + } + + result.push(view.contentWindow); + } + + return result; + }; + } + + return { extension: api }; + } +}; diff --git a/toolkit/components/extensions/child/ext-identity.js b/toolkit/components/extensions/child/ext-identity.js new file mode 100644 index 0000000000..9218065322 --- /dev/null +++ b/toolkit/components/extensions/child/ext-identity.js @@ -0,0 +1,84 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +var { Constructor: CC } = Components; + +ChromeUtils.defineESModuleGetters(this, { + CommonUtils: "resource://services-common/utils.sys.mjs", +}); +XPCOMUtils.defineLazyPreferenceGetter( + this, + "redirectDomain", + "extensions.webextensions.identity.redirectDomain" +); + +let CryptoHash = CC( + "@mozilla.org/security/hash;1", + "nsICryptoHash", + "initWithString" +); + +XPCOMUtils.defineLazyGlobalGetters(this, ["URL", "TextEncoder"]); + +const computeHash = str => { + let byteArr = new TextEncoder().encode(str); + let hash = new CryptoHash("sha1"); + hash.update(byteArr, byteArr.length); + return CommonUtils.bytesAsHex(hash.finish(false)); +}; + +this.identity = class extends ExtensionAPI { + getAPI(context) { + let { extension } = context; + return { + identity: { + getRedirectURL: function (path = "") { + let hash = computeHash(extension.id); + let url = new URL(`https://${hash}.${redirectDomain}/`); + url.pathname = path; + return url.href; + }, + launchWebAuthFlow: function (details) { + // Validate the url and retreive redirect_uri if it was provided. + let url, redirectURI; + let baseRedirectURL = this.getRedirectURL(); + + // Allow using loopback address for native OAuth flows as some + // providers do not accept the URL provided by getRedirectURL. + // For more context, see bug 1635344. + let loopbackURL = `http://127.0.0.1/mozoauth2/${computeHash( + extension.id + )}`; + try { + url = new URL(details.url); + } catch (e) { + return Promise.reject({ message: "details.url is invalid" }); + } + try { + redirectURI = new URL( + url.searchParams.get("redirect_uri") || baseRedirectURL + ); + if ( + !redirectURI.href.startsWith(baseRedirectURL) && + !redirectURI.href.startsWith(loopbackURL) + ) { + return Promise.reject({ message: "redirect_uri not allowed" }); + } + } catch (e) { + return Promise.reject({ message: "redirect_uri is invalid" }); + } + + return context.childManager.callParentAsyncFunction( + "identity.launchWebAuthFlowInParent", + [details, redirectURI.href] + ); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-runtime.js b/toolkit/components/extensions/child/ext-runtime.js new file mode 100644 index 0000000000..8cf5c445e3 --- /dev/null +++ b/toolkit/components/extensions/child/ext-runtime.js @@ -0,0 +1,143 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.sys.mjs", +}); + +/* eslint-disable jsdoc/check-param-names */ +/** + * With optional arguments on both ends, this case is ambiguous: + * runtime.sendMessage("string", {} or nullish) + * + * Sending a message within the extension is more common than sending + * an empty object to another extension, so we prefer that conclusion. + * + * @param {string?} [extensionId] + * @param {any} message + * @param {object?} [options] + * @param {Function} [callback] + * @returns {{extensionId: string?, message: any, callback: Function?}} + */ +/* eslint-enable jsdoc/check-param-names */ +function parseBonkersArgs(...args) { + let Error = ExtensionUtils.ExtensionError; + let callback = typeof args[args.length - 1] === "function" && args.pop(); + + // We don't support any options anymore, so only an empty object is valid. + function validOptions(v) { + return v == null || (typeof v === "object" && !Object.keys(v).length); + } + + if (args.length === 1 || (args.length === 2 && validOptions(args[1]))) { + // Interpret as passing null for extensionId (message within extension). + args.unshift(null); + } + let [extensionId, message, options] = args; + + if (!args.length) { + throw new Error("runtime.sendMessage's message argument is missing"); + } else if (!validOptions(options)) { + throw new Error("runtime.sendMessage's options argument is invalid"); + } else if (args.length === 4 && args[3] && !callback) { + throw new Error("runtime.sendMessage's last argument is not a function"); + } else if (args[3] != null || args.length > 4) { + throw new Error("runtime.sendMessage received too many arguments"); + } else if (extensionId && typeof extensionId !== "string") { + throw new Error("runtime.sendMessage's extensionId argument is invalid"); + } + return { extensionId, message, callback }; +} + +this.runtime = class extends ExtensionAPI { + getAPI(context) { + let { extension } = context; + + return { + runtime: { + onConnect: context.messenger.onConnect.api(), + onMessage: context.messenger.onMessage.api(), + + onConnectExternal: context.messenger.onConnectEx.api(), + onMessageExternal: context.messenger.onMessageEx.api(), + + connect(extensionId, options) { + let name = options?.name ?? ""; + return context.messenger.connect({ name, extensionId }); + }, + + sendMessage(...args) { + let arg = parseBonkersArgs(...args); + return context.messenger.sendRuntimeMessage(arg); + }, + + connectNative(name) { + return context.messenger.connect({ name, native: true }); + }, + + sendNativeMessage(nativeApp, message) { + return context.messenger.sendNativeMessage(nativeApp, message); + }, + + get lastError() { + return context.lastError; + }, + + getManifest() { + return Cu.cloneInto(extension.manifest, context.cloneScope); + }, + + id: extension.id, + + getURL(url) { + return extension.baseURI.resolve(url); + }, + + getFrameId(target) { + let frameId = WebNavigationFrames.getFromWindow(target); + if (frameId >= 0) { + return frameId; + } + // Not a WindowProxy, perhaps an embedder element? + + let type; + try { + type = Cu.getClassName(target, true); + } catch (e) { + // Not a valid object, will throw below. + } + + const embedderTypes = [ + "HTMLIFrameElement", + "HTMLFrameElement", + "HTMLEmbedElement", + "HTMLObjectElement", + ]; + + if (embedderTypes.includes(type)) { + if (!target.browsingContext) { + return -1; + } + return WebNavigationFrames.getFrameId(target.browsingContext); + } + + throw new ExtensionUtils.ExtensionError("Invalid argument"); + }, + }, + }; + } + + getAPIObjectForRequest(context, request) { + if (request.apiObjectType === "Port") { + const port = context.messenger.getPortById(request.apiObjectId); + if (!port) { + throw new Error(`Port API object not found: ${request}`); + } + return port.api; + } + + throw new Error(`Unexpected apiObjectType: ${request}`); + } +}; diff --git a/toolkit/components/extensions/child/ext-scripting.js b/toolkit/components/extensions/child/ext-scripting.js new file mode 100644 index 0000000000..cce587227f --- /dev/null +++ b/toolkit/components/extensions/child/ext-scripting.js @@ -0,0 +1,49 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +var { ExtensionError } = ExtensionUtils; + +this.scripting = class extends ExtensionAPI { + getAPI(context) { + return { + scripting: { + executeScript: async details => { + let { func, args, ...parentDetails } = details; + + if (details.files) { + if (details.args) { + throw new ExtensionError( + "'args' may not be used with file injections." + ); + } + } + // `files` and `func` are mutually exclusive but that is checked in + // the parent (in `execute()`). + if (func) { + try { + const serializedArgs = args + ? JSON.stringify(args).slice(1, -1) + : ""; + // This is a prop that we compute here and pass to the parent. + parentDetails.func = `(${func.toString()})(${serializedArgs});`; + } catch (e) { + throw new ExtensionError("Unserializable arguments."); + } + } else { + parentDetails.func = null; + } + + return context.childManager.callParentAsyncFunction( + "scripting.executeScriptInternal", + [parentDetails] + ); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-storage.js b/toolkit/components/extensions/child/ext-storage.js new file mode 100644 index 0000000000..2d10964d0a --- /dev/null +++ b/toolkit/components/extensions/child/ext-storage.js @@ -0,0 +1,368 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionStorage: "resource://gre/modules/ExtensionStorage.sys.mjs", + ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.sys.mjs", + ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs", +}); + +// Wrap a storage operation in a TelemetryStopWatch. +async function measureOp(telemetryMetric, extension, fn) { + const stopwatchKey = {}; + telemetryMetric.stopwatchStart(extension, stopwatchKey); + try { + let result = await fn(); + telemetryMetric.stopwatchFinish(extension, stopwatchKey); + return result; + } catch (err) { + telemetryMetric.stopwatchCancel(extension, stopwatchKey); + throw err; + } +} + +this.storage = class extends ExtensionAPI { + getLocalFileBackend(context, { deserialize, serialize }) { + return { + get(keys) { + return measureOp( + ExtensionTelemetry.storageLocalGetJson, + context.extension, + () => { + return context.childManager + .callParentAsyncFunction("storage.local.JSONFileBackend.get", [ + serialize(keys), + ]) + .then(deserialize); + } + ); + }, + set(items) { + return measureOp( + ExtensionTelemetry.storageLocalSetJson, + context.extension, + () => { + return context.childManager.callParentAsyncFunction( + "storage.local.JSONFileBackend.set", + [serialize(items)] + ); + } + ); + }, + remove(keys) { + return context.childManager.callParentAsyncFunction( + "storage.local.JSONFileBackend.remove", + [serialize(keys)] + ); + }, + clear() { + return context.childManager.callParentAsyncFunction( + "storage.local.JSONFileBackend.clear", + [] + ); + }, + }; + } + + getLocalIDBBackend(context, { fireOnChanged, serialize, storagePrincipal }) { + let dbPromise; + async function getDB() { + if (dbPromise) { + return dbPromise; + } + + const persisted = context.extension.hasPermission("unlimitedStorage"); + dbPromise = ExtensionStorageIDB.open(storagePrincipal, persisted).catch( + err => { + // Reset the cached promise if it has been rejected, so that the next + // API call is going to retry to open the DB. + dbPromise = null; + throw err; + } + ); + + return dbPromise; + } + + return { + get(keys) { + return measureOp( + ExtensionTelemetry.storageLocalGetIdb, + context.extension, + async () => { + const db = await getDB(); + return db.get(keys); + } + ); + }, + set(items) { + function serialize(name, anonymizedName, value) { + return ExtensionStorage.serialize( + `set/${context.extension.id}/${name}`, + `set/${context.extension.id}/${anonymizedName}`, + value + ); + } + + return measureOp( + ExtensionTelemetry.storageLocalSetIdb, + context.extension, + async () => { + const db = await getDB(); + const changes = await db.set(items, { + serialize, + }); + + if (changes) { + fireOnChanged(changes); + } + } + ); + }, + async remove(keys) { + const db = await getDB(); + const changes = await db.remove(keys); + + if (changes) { + fireOnChanged(changes); + } + }, + async clear() { + const db = await getDB(); + const changes = await db.clear(context.extension); + + if (changes) { + fireOnChanged(changes); + } + }, + }; + } + + getAPI(context) { + const { extension } = context; + const serialize = ExtensionStorage.serializeForContext.bind(null, context); + const deserialize = ExtensionStorage.deserializeForContext.bind( + null, + context + ); + + // onChangedName is "storage.onChanged", "storage.sync.onChanged", etc. + function makeOnChangedEventTarget(onChangedName) { + return new EventManager({ + context, + name: onChangedName, + register: fire => { + let onChanged = (data, area) => { + let changes = new context.cloneScope.Object(); + for (let [key, value] of Object.entries(data)) { + changes[key] = deserialize(value); + } + if (area) { + // storage.onChanged includes the area. + fire.raw(changes, area); + } else { + // StorageArea.onChanged doesn't include the area. + fire.raw(changes); + } + }; + + let parent = context.childManager.getParentEvent(onChangedName); + parent.addListener(onChanged); + return () => { + parent.removeListener(onChanged); + }; + }, + }).api(); + } + + function sanitize(items) { + // The schema validator already takes care of arrays (which are only allowed + // to contain strings). Strings and null are safe values. + if (typeof items != "object" || items === null || Array.isArray(items)) { + return items; + } + // If we got here, then `items` is an object generated by `ObjectType`'s + // `normalize` method from Schemas.jsm. The object returned by `normalize` + // lives in this compartment, while the values live in compartment of + // `context.contentWindow`. The `sanitize` method runs with the principal + // of `context`, so we cannot just use `ExtensionStorage.sanitize` because + // it is not allowed to access properties of `items`. + // So we enumerate all properties and sanitize each value individually. + let sanitized = {}; + for (let [key, value] of Object.entries(items)) { + sanitized[key] = ExtensionStorage.sanitize(value, context); + } + return sanitized; + } + + function fireOnChanged(changes) { + // This call is used (by the storage.local API methods for the IndexedDB backend) to fire a storage.onChanged event, + // it uses the underlying message manager since the child context (or its ProxyContentParent counterpart + // running in the main process) may be gone by the time we call this, and so we can't use the childManager + // abstractions (e.g. callParentAsyncFunction or callParentFunctionNoReturn). + Services.cpmm.sendAsyncMessage( + `Extension:StorageLocalOnChanged:${extension.uuid}`, + changes + ); + } + + // If the selected backend for the extension is not known yet, we have to lazily detect it + // by asking to the main process (as soon as the storage.local API has been accessed for + // the first time). + const getStorageLocalBackend = async () => { + const { backendEnabled, storagePrincipal } = + await ExtensionStorageIDB.selectBackend(context); + + if (!backendEnabled) { + return this.getLocalFileBackend(context, { deserialize, serialize }); + } + + return this.getLocalIDBBackend(context, { + storagePrincipal, + fireOnChanged, + serialize, + }); + }; + + // Synchronously select the backend if it is already known. + let selectedBackend; + + const useStorageIDBBackend = extension.getSharedData("storageIDBBackend"); + if (useStorageIDBBackend === false) { + selectedBackend = this.getLocalFileBackend(context, { + deserialize, + serialize, + }); + } else if (useStorageIDBBackend === true) { + selectedBackend = this.getLocalIDBBackend(context, { + storagePrincipal: extension.getSharedData("storageIDBPrincipal"), + fireOnChanged, + serialize, + }); + } + + let promiseStorageLocalBackend; + + // Generate the backend-agnostic local API wrapped methods. + const local = { + onChanged: makeOnChangedEventTarget("storage.local.onChanged"), + }; + for (let method of ["get", "set", "remove", "clear"]) { + local[method] = async function (...args) { + try { + // Discover the selected backend if it is not known yet. + if (!selectedBackend) { + if (!promiseStorageLocalBackend) { + promiseStorageLocalBackend = getStorageLocalBackend().catch( + err => { + // Clear the cached promise if it has been rejected. + promiseStorageLocalBackend = null; + throw err; + } + ); + } + + // If the storage.local method is not 'get' (which doesn't change any of the stored data), + // fall back to call the method in the parent process, so that it can be completed even + // if this context has been destroyed in the meantime. + if (method !== "get") { + // Let the outer try to catch rejections returned by the backend methods. + try { + const result = + await context.childManager.callParentAsyncFunction( + "storage.local.callMethodInParentProcess", + [method, args] + ); + return result; + } catch (err) { + // Just return the rejection as is, the error has been normalized in the + // parent process by callMethodInParentProcess and the original error + // logged in the browser console. + return Promise.reject(err); + } + } + + // Get the selected backend and cache it for the next API calls from this context. + selectedBackend = await promiseStorageLocalBackend; + } + + // Let the outer try to catch rejections returned by the backend methods. + const result = await selectedBackend[method](...args); + return result; + } catch (err) { + throw ExtensionStorageIDB.normalizeStorageError({ + error: err, + extensionId: extension.id, + storageMethod: method, + }); + } + }; + } + + return { + storage: { + local, + + session: { + async get(keys) { + return deserialize( + await context.childManager.callParentAsyncFunction( + "storage.session.get", + [serialize(keys)] + ) + ); + }, + set(items) { + return context.childManager.callParentAsyncFunction( + "storage.session.set", + [serialize(items)] + ); + }, + onChanged: makeOnChangedEventTarget("storage.session.onChanged"), + }, + + sync: { + get(keys) { + keys = sanitize(keys); + return context.childManager.callParentAsyncFunction( + "storage.sync.get", + [keys] + ); + }, + set(items) { + items = sanitize(items); + return context.childManager.callParentAsyncFunction( + "storage.sync.set", + [items] + ); + }, + onChanged: makeOnChangedEventTarget("storage.sync.onChanged"), + }, + + managed: { + get(keys) { + return context.childManager + .callParentAsyncFunction("storage.managed.get", [serialize(keys)]) + .then(deserialize); + }, + set(items) { + return Promise.reject({ message: "storage.managed is read-only" }); + }, + remove(keys) { + return Promise.reject({ message: "storage.managed is read-only" }); + }, + clear() { + return Promise.reject({ message: "storage.managed is read-only" }); + }, + + onChanged: makeOnChangedEventTarget("storage.managed.onChanged"), + }, + + onChanged: makeOnChangedEventTarget("storage.onChanged"), + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-test.js b/toolkit/components/extensions/child/ext-test.js new file mode 100644 index 0000000000..a4178b63ff --- /dev/null +++ b/toolkit/components/extensions/child/ext-test.js @@ -0,0 +1,371 @@ +/* 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"; + +ChromeUtils.defineLazyGetter(this, "isXpcshell", function () { + return Services.env.exists("XPCSHELL_TEST_PROFILE_DIR"); +}); + +/** + * Checks whether the given error matches the given expectations. + * + * @param {*} error + * The error to check. + * @param {string | RegExp | Function | null} expectedError + * The expectation to check against. If this parameter is: + * + * - a string, the error message must exactly equal the string. + * - a regular expression, it must match the error message. + * - a function, it is called with the error object and its + * return value is returned. + * @param {BaseContext} context + * + * @returns {boolean} + * True if the error matches the expected error. + */ +const errorMatches = (error, expectedError, context) => { + if ( + typeof error === "object" && + error !== null && + !context.principal.subsumes(Cu.getObjectPrincipal(error)) + ) { + Cu.reportError("Error object belongs to the wrong scope."); + return false; + } + + if (typeof expectedError === "function") { + return context.runSafeWithoutClone(expectedError, error); + } + + if ( + typeof error !== "object" || + error == null || + typeof error.message !== "string" + ) { + return false; + } + + if (typeof expectedError === "string") { + return error.message === expectedError; + } + + try { + return expectedError.test(error.message); + } catch (e) { + Cu.reportError(e); + } + + return false; +}; + +// Checks whether |v| should use string serialization instead of JSON. +function useStringInsteadOfJSON(v) { + return ( + // undefined to string, or else it is omitted from object after stringify. + v === undefined || + // Values that would have become null. + (typeof v === "number" && (isNaN(v) || !isFinite(v))) + ); +} + +// A very strict deep equality comparator that throws for unsupported values. +// For context, see https://bugzilla.mozilla.org/show_bug.cgi?id=1782816#c2 +function deepEquals(a, b) { + // Some values don't have a JSON representation. To disambiguate from null or + // regular strings, we prepend this prefix instead. + const NON_JSON_PREFIX = "#NOT_JSON_SERIALIZABLE#"; + + function replacer(key, value) { + if (typeof value == "object" && value !== null && !Array.isArray(value)) { + const cls = ChromeUtils.getClassName(value); + if (cls === "Object") { + // Return plain object with keys sorted in a predictable order. + return Object.fromEntries( + Object.keys(value) + .sort() + .map(k => [k, value[k]]) + ); + } + // Just throw to avoid potentially inaccurate serializations (e.g. {}). + throw new ExtensionUtils.ExtensionError(`Unsupported obj type: ${cls}`); + } + + if (useStringInsteadOfJSON(value)) { + return `${NON_JSON_PREFIX}${value}`; + } + return value; + } + return JSON.stringify(a, replacer) === JSON.stringify(b, replacer); +} + +/** + * Serializes the given value for use in informative assertion messages. + * + * @param {*} value + * @returns {string} + */ +const toSource = value => { + function cannotJSONserialize(v) { + return ( + useStringInsteadOfJSON(v) || + // Not a plain object. E.g. [object X], /regexp/, etc. + (typeof v == "object" && + v !== null && + !Array.isArray(v) && + ChromeUtils.getClassName(v) !== "Object") + ); + } + try { + if (cannotJSONserialize(value)) { + return String(value); + } + + const replacer = (k, v) => (cannotJSONserialize(v) ? String(v) : v); + return JSON.stringify(value, replacer); + } catch (e) { + return ""; + } +}; + +this.test = class extends ExtensionAPI { + getAPI(context) { + const { extension } = context; + + function getStack(savedFrame = null) { + if (savedFrame) { + return ChromeUtils.createError("", savedFrame).stack.replace( + /^/gm, + " " + ); + } + return new context.Error().stack.replace(/^/gm, " "); + } + + function assertTrue(value, msg) { + extension.emit( + "test-result", + Boolean(value), + String(msg), + getStack(context.getCaller()) + ); + } + + class TestEventManager extends EventManager { + constructor(...args) { + super(...args); + + // A map to keep track of the listeners wrappers being added in + // addListener (the wrapper will be needed to be able to remove + // the listener from this EventManager instance if the extension + // does call test.onMessage.removeListener). + this._listenerWrappers = new Map(); + context.callOnClose({ + close: () => this._listenerWrappers.clear(), + }); + } + + addListener(callback, ...args) { + const listenerWrapper = function (...args) { + try { + callback.call(this, ...args); + } catch (e) { + assertTrue(false, `${e}\n${e.stack}`); + } + }; + super.addListener(listenerWrapper, ...args); + this._listenerWrappers.set(callback, listenerWrapper); + } + + removeListener(callback) { + if (!this._listenerWrappers.has(callback)) { + return; + } + + super.removeListener(this._listenerWrappers.get(callback)); + this._listenerWrappers.delete(callback); + } + } + + if (!Cu.isInAutomation && !isXpcshell) { + return { test: {} }; + } + + return { + test: { + withHandlingUserInput(callback) { + // TODO(Bug 1598804): remove this once we don't expose anymore the + // entire test API namespace based on an environment variable. + if (!Cu.isInAutomation) { + // This dangerous method should only be available if the + // automation pref is set, which is the case in browser tests. + throw new ExtensionUtils.ExtensionError( + "withHandlingUserInput can only be called in automation" + ); + } + ExtensionCommon.withHandlingUserInput( + context.contentWindow, + callback + ); + }, + + sendMessage(...args) { + extension.emit("test-message", ...args); + }, + + notifyPass(msg) { + extension.emit("test-done", true, msg, getStack(context.getCaller())); + }, + + notifyFail(msg) { + extension.emit( + "test-done", + false, + msg, + getStack(context.getCaller()) + ); + }, + + log(msg) { + extension.emit("test-log", true, msg, getStack(context.getCaller())); + }, + + fail(msg) { + assertTrue(false, msg); + }, + + succeed(msg) { + assertTrue(true, msg); + }, + + assertTrue(value, msg) { + assertTrue(value, msg); + }, + + assertFalse(value, msg) { + assertTrue(!value, msg); + }, + + assertDeepEq(expected, actual, msg) { + // The bindings generated by Schemas.jsm accepts any input, but the + // WebIDL-generated binding expects a structurally cloneable input. + // To ensure consistent behavior regardless of which mechanism was + // used, verify that the inputs are structurally cloneable. + // These will throw if the values cannot be cloned. + function ensureStructurallyCloneable(v) { + if (typeof v == "object" && v !== null) { + // Waive xrays to unhide callable members, so that cloneInto will + // throw if needed. + v = ChromeUtils.waiveXrays(v); + } + new StructuredCloneHolder("test.assertEq", null, v, globalThis); + } + // When WebIDL bindings are used, the objects are already cloned + // structurally, so we don't need to check again. + if (!context.useWebIDLBindings) { + ensureStructurallyCloneable(expected); + ensureStructurallyCloneable(actual); + } + + extension.emit( + "test-eq", + deepEquals(actual, expected), + String(msg), + toSource(expected), + toSource(actual), + getStack(context.getCaller()) + ); + }, + + assertEq(expected, actual, msg) { + let equal = expected === actual; + + expected = String(expected); + actual = String(actual); + + if (!equal && expected === actual) { + actual += " (different)"; + } + extension.emit( + "test-eq", + equal, + String(msg), + expected, + actual, + getStack(context.getCaller()) + ); + }, + + assertRejects(promise, expectedError, msg) { + // Wrap in a native promise for consistency. + promise = Promise.resolve(promise); + + return promise.then( + result => { + let message = `Promise resolved, expected rejection '${toSource( + expectedError + )}'`; + if (msg) { + message += `: ${msg}`; + } + assertTrue(false, message); + }, + error => { + let expected = toSource(expectedError); + let message = `got '${toSource(error)}'`; + if (msg) { + message += `: ${msg}`; + } + + assertTrue( + errorMatches(error, expectedError, context), + `Promise rejected, expecting rejection to match '${expected}', ${message}` + ); + } + ); + }, + + assertThrows(func, expectedError, msg) { + try { + func(); + + let message = `Function did not throw, expected error '${toSource( + expectedError + )}'`; + if (msg) { + message += `: ${msg}`; + } + assertTrue(false, message); + } catch (error) { + let expected = toSource(expectedError); + let message = `got '${toSource(error)}'`; + if (msg) { + message += `: ${msg}`; + } + + assertTrue( + errorMatches(error, expectedError, context), + `Function threw, expecting error to match '${expected}', ${message}` + ); + } + }, + + onMessage: new TestEventManager({ + context, + name: "test.onMessage", + register: fire => { + let handler = (event, ...args) => { + fire.async(...args); + }; + + extension.on("test-harness-message", handler); + return () => { + extension.off("test-harness-message", handler); + }; + }, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-toolkit.js b/toolkit/components/extensions/child/ext-toolkit.js new file mode 100644 index 0000000000..0786880961 --- /dev/null +++ b/toolkit/components/extensions/child/ext-toolkit.js @@ -0,0 +1,84 @@ +/* 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"; + +global.EventManager = ExtensionCommon.EventManager; + +extensions.registerModules({ + backgroundPage: { + url: "chrome://extensions/content/child/ext-backgroundPage.js", + scopes: ["addon_child"], + manifest: ["background"], + paths: [ + ["extension", "getBackgroundPage"], + ["runtime", "getBackgroundPage"], + ], + }, + contentScripts: { + url: "chrome://extensions/content/child/ext-contentScripts.js", + scopes: ["addon_child"], + paths: [["contentScripts"]], + }, + declarativeNetRequest: { + url: "chrome://extensions/content/child/ext-declarativeNetRequest.js", + scopes: ["addon_child"], + paths: [["declarativeNetRequest"]], + }, + extension: { + url: "chrome://extensions/content/child/ext-extension.js", + scopes: ["addon_child", "content_child", "devtools_child"], + paths: [["extension"]], + }, + i18n: { + url: "chrome://extensions/content/parent/ext-i18n.js", + scopes: ["addon_child", "content_child", "devtools_child"], + paths: [["i18n"]], + }, + runtime: { + url: "chrome://extensions/content/child/ext-runtime.js", + scopes: ["addon_child", "content_child", "devtools_child"], + paths: [["runtime"]], + }, + scripting: { + url: "chrome://extensions/content/child/ext-scripting.js", + scopes: ["addon_child"], + paths: [["scripting"]], + }, + storage: { + url: "chrome://extensions/content/child/ext-storage.js", + scopes: ["addon_child", "content_child", "devtools_child"], + paths: [["storage"]], + }, + test: { + url: "chrome://extensions/content/child/ext-test.js", + scopes: ["addon_child", "content_child", "devtools_child"], + paths: [["test"]], + }, + userScripts: { + url: "chrome://extensions/content/child/ext-userScripts.js", + scopes: ["addon_child"], + paths: [["userScripts"]], + }, + userScriptsContent: { + url: "chrome://extensions/content/child/ext-userScripts-content.js", + scopes: ["content_child"], + paths: [["userScripts", "onBeforeScript"]], + }, + webRequest: { + url: "chrome://extensions/content/child/ext-webRequest.js", + scopes: ["addon_child"], + paths: [["webRequest"]], + }, +}); + +if (AppConstants.MOZ_BUILD_APP === "browser") { + extensions.registerModules({ + identity: { + url: "chrome://extensions/content/child/ext-identity.js", + scopes: ["addon_child"], + paths: [["identity"]], + }, + }); +} diff --git a/toolkit/components/extensions/child/ext-userScripts-content.js b/toolkit/components/extensions/child/ext-userScripts-content.js new file mode 100644 index 0000000000..ee1a1b7a8f --- /dev/null +++ b/toolkit/components/extensions/child/ext-userScripts-content.js @@ -0,0 +1,408 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +var USERSCRIPT_PREFNAME = "extensions.webextensions.userScripts.enabled"; +var USERSCRIPT_DISABLED_ERRORMSG = `userScripts APIs are currently experimental and must be enabled with the ${USERSCRIPT_PREFNAME} preference.`; + +ChromeUtils.defineESModuleGetters(this, { + Schemas: "resource://gre/modules/Schemas.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "userScriptsEnabled", + USERSCRIPT_PREFNAME, + false +); + +var { ExtensionError } = ExtensionUtils; + +const TYPEOF_PRIMITIVES = ["bigint", "boolean", "number", "string", "symbol"]; + +/** + * Represents a user script in the child content process. + * + * This class implements the API object that is passed as a parameter to the + * browser.userScripts.onBeforeScript API Event. + * + * @param {object} params + * @param {ContentScriptContextChild} params.context + * The context which has registered the userScripts.onBeforeScript listener. + * @param {PlainJSONValue} params.metadata + * An opaque user script metadata value (as set in userScripts.register). + * @param {Sandbox} params.scriptSandbox + * The Sandbox object of the userScript. + */ +class UserScript { + constructor({ context, metadata, scriptSandbox }) { + this.context = context; + this.extension = context.extension; + this.apiSandbox = context.cloneScope; + this.metadata = metadata; + this.scriptSandbox = scriptSandbox; + + this.ScriptError = scriptSandbox.Error; + this.ScriptPromise = scriptSandbox.Promise; + } + + /** + * Returns the API object provided to the userScripts.onBeforeScript listeners. + * + * @returns {object} + * The API object with the properties and methods to export + * to the extension code. + */ + api() { + return { + metadata: this.metadata, + defineGlobals: sourceObject => this.defineGlobals(sourceObject), + export: value => this.export(value), + }; + } + + /** + * Define all the properties of a given plain object as lazy getters of the + * userScript global object. + * + * @param {object} sourceObject + * A set of objects and methods to export into the userScript scope as globals. + * + * @throws {context.Error} + * Throws an apiScript error when sourceObject is not a plain object. + */ + defineGlobals(sourceObject) { + let className; + try { + className = ChromeUtils.getClassName(sourceObject, true); + } catch (e) { + // sourceObject is not an object; + } + + if (className !== "Object") { + throw new this.context.Error( + "Invalid sourceObject type, plain object expected." + ); + } + + this.exportLazyGetters(sourceObject, this.scriptSandbox); + } + + /** + * Convert a given value to make it accessible to the userScript code. + * + * - any property value that is already accessible to the userScript code is returned unmodified by + * the lazy getter + * - any apiScript's Function is wrapped using the `wrapFunction` method + * - any apiScript's Object is lazily exported (and the same wrappers are lazily applied to its + * properties). + * + * @param {any} valueToExport + * A value to convert into an object accessible to the userScript. + * + * @param {object} privateOptions + * A set of options used when this method is called internally (not exposed in the + * api object exported to the onBeforeScript listeners). + * @param {Error} privateOptions.Error + * The Error constructor to use to report errors (defaults to the apiScript context's Error + * when missing). + * @param {Error} privateOptions.errorMessage + * A custom error message to report exporting error on values not allowed. + * + * @returns {any} + * The resulting userScript object. + * + * @throws {context.Error | privateOptions.Error} + * Throws an error when the value is not allowed and it can't be exported into an allowed one. + */ + export(valueToExport, privateOptions = {}) { + const ExportError = privateOptions.Error || this.context.Error; + + if (this.canAccess(valueToExport, this.scriptSandbox)) { + // Return the value unmodified if the userScript principal is already allowed + // to access it. + return valueToExport; + } + + let className; + + try { + className = ChromeUtils.getClassName(valueToExport, true); + } catch (e) { + // sourceObject is not an object; + } + + if (className === "Function") { + return this.wrapFunction(valueToExport); + } + + if (className === "Object") { + return this.exportLazyGetters(valueToExport); + } + + if (className === "Array") { + return this.exportArray(valueToExport); + } + + let valueType = className || typeof valueToExport; + throw new ExportError( + privateOptions.errorMessage || + `${valueType} cannot be exported to the userScript` + ); + } + + /** + * Export all the elements of the `srcArray` into a newly created userScript array. + * + * @param {Array} srcArray + * The apiScript array to export to the userScript code. + * + * @returns {Array} + * The resulting userScript array. + * + * @throws {UserScriptError} + * Throws an error when the array can't be exported successfully. + */ + exportArray(srcArray) { + const destArray = Cu.cloneInto([], this.scriptSandbox); + + for (let [idx, value] of this.shallowCloneEntries(srcArray)) { + destArray[idx] = this.export(value, { + errorMessage: `Error accessing disallowed element at index "${idx}"`, + Error: this.UserScriptError, + }); + } + + return destArray; + } + + /** + * Export all the properties of the `src` plain object as lazy getters on the `dest` object, + * or in a newly created userScript object if `dest` is `undefined`. + * + * @param {object} src + * A set of properties to define on a `dest` object as lazy getters. + * @param {object} [dest] + * An optional `dest` object (a new userScript object is created by default when not specified). + * + * @returns {object} + * The resulting userScript object. + */ + exportLazyGetters(src, dest = undefined) { + dest = dest || Cu.createObjectIn(this.scriptSandbox); + + for (let [key, value] of this.shallowCloneEntries(src)) { + Schemas.exportLazyGetter(dest, key, () => { + return this.export(value, { + // Lazy properties will raise an error for properties with not allowed + // values to the userScript scope, and so we have to raise an userScript + // Error here. + Error: this.ScriptError, + errorMessage: `Error accessing disallowed property "${key}"`, + }); + }); + } + + return dest; + } + + /** + * Export and wrap an apiScript function to provide the following behaviors: + * - errors throws from an exported function are checked by `handleAPIScriptError` + * - returned apiScript's Promises (not accessible to the userScript) are converted into a + * userScript's Promise + * - check if the returned or resolved value is accessible to the userScript code + * (and raise a userScript error if it is not) + * + * @param {Function} fn + * The apiScript function to wrap + * + * @returns {object} + * The resulting userScript function. + */ + wrapFunction(fn) { + return Cu.exportFunction((...args) => { + let res; + try { + // Checks that all the elements in the `...args` array are allowed to be + // received from the apiScript. + for (let arg of args) { + if (!this.canAccess(arg, this.apiSandbox)) { + throw new this.ScriptError( + `Parameter not accessible to the userScript API` + ); + } + } + + res = fn(...args); + } catch (err) { + this.handleAPIScriptError(err); + } + + // Prevent execution of proxy traps while checking if the return value is a Promise. + if (!Cu.isProxy(res) && res instanceof this.context.Promise) { + return this.ScriptPromise.resolve().then(async () => { + let value; + + try { + value = await res; + } catch (err) { + this.handleAPIScriptError(err); + } + + return this.ensureAccessible(value); + }); + } + + return this.ensureAccessible(res); + }, this.scriptSandbox); + } + + /** + * Shallow clone the source object and iterate over its Object properties (or Array elements), + * which allow us to safely iterate over all its properties (including callable objects that + * would be hidden by the xrays vision, but excluding any property that could be tricky, e.g. + * getters). + * + * @param {object | Array} obj + * The Object or Array object to shallow clone and iterate over. + */ + *shallowCloneEntries(obj) { + const clonedObj = ChromeUtils.shallowClone(obj); + + for (let entry of Object.entries(clonedObj)) { + yield entry; + } + } + + /** + * Check if the given value is accessible to the targetScope. + * + * @param {any} val + * The value to check. + * @param {Sandbox} targetScope + * The targetScope that should be able to access the value. + * + * @returns {boolean} + */ + canAccess(val, targetScope) { + if (val == null || TYPEOF_PRIMITIVES.includes(typeof val)) { + return true; + } + + // Disallow objects that are coming from principals that are not + // subsumed by the targetScope's principal. + try { + const targetPrincipal = Cu.getObjectPrincipal(targetScope); + if (!targetPrincipal.subsumes(Cu.getObjectPrincipal(val))) { + return false; + } + } catch (err) { + Cu.reportError(err); + return false; + } + + return true; + } + + /** + * Check if the value returned (or resolved) from an apiScript method is accessible + * to the userScript code, and throw a userScript Error if it is not allowed. + * + * @param {any} res + * The value to return/resolve. + * + * @returns {any} + * The exported value. + * + * @throws {Error} + * Throws a userScript error when the value is not accessible to the userScript scope. + */ + ensureAccessible(res) { + if (this.canAccess(res, this.scriptSandbox)) { + return res; + } + + throw new this.ScriptError("Return value not accessible to the userScript"); + } + + /** + * Handle the error raised (and rejected promise returned) from apiScript functions exported to the + * userScript. + * + * @param {any} err + * The value to return/resolve. + * + * @throws {any} + * This method is expected to throw: + * - any value that is already accessible to the userScript code is forwarded unmodified + * - any value that is not accessible to the userScript code is logged in the console + * (to make it easier to investigate the underlying issue) and converted into a + * userScript Error (with the generic "An unexpected apiScript error occurred" error + * message accessible to the userScript) + */ + handleAPIScriptError(err) { + if (this.canAccess(err, this.scriptSandbox)) { + throw err; + } + + // Log the actual error on the console and raise a generic userScript Error + // on error objects that can't be accessed by the UserScript principal. + try { + const debugName = this.extension.policy.debugName; + Cu.reportError( + `An unexpected apiScript error occurred for '${debugName}': ${err} :: ${err.stack}` + ); + } catch (e) {} + + throw new this.ScriptError(`An unexpected apiScript error occurred`); + } +} + +this.userScriptsContent = class extends ExtensionAPI { + getAPI(context) { + return { + userScripts: { + onBeforeScript: new EventManager({ + context, + name: "userScripts.onBeforeScript", + register: fire => { + if (!userScriptsEnabled) { + throw new ExtensionError(USERSCRIPT_DISABLED_ERRORMSG); + } + + let handler = (event, metadata, scriptSandbox, eventResult) => { + const us = new UserScript({ + context, + metadata, + scriptSandbox, + }); + + const apiObj = Cu.cloneInto(us.api(), context.cloneScope, { + cloneFunctions: true, + }); + + Object.defineProperty(apiObj, "global", { + value: scriptSandbox, + enumerable: true, + configurable: true, + writable: true, + }); + + fire.raw(apiObj); + }; + + context.userScriptsEvents.on("on-before-script", handler); + return () => { + context.userScriptsEvents.off("on-before-script", handler); + }; + }, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-userScripts.js b/toolkit/components/extensions/child/ext-userScripts.js new file mode 100644 index 0000000000..66cfeb0906 --- /dev/null +++ b/toolkit/components/extensions/child/ext-userScripts.js @@ -0,0 +1,192 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +var USERSCRIPT_PREFNAME = "extensions.webextensions.userScripts.enabled"; +var USERSCRIPT_DISABLED_ERRORMSG = `userScripts APIs are currently experimental and must be enabled with the ${USERSCRIPT_PREFNAME} preference.`; + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "userScriptsEnabled", + USERSCRIPT_PREFNAME, + false +); + +// eslint-disable-next-line mozilla/reject-importGlobalProperties +Cu.importGlobalProperties(["crypto", "TextEncoder"]); + +var { DefaultMap, ExtensionError, getUniqueId } = ExtensionUtils; + +/** + * Represents a registered userScript in the child extension process. + * + * @param {ExtensionPageContextChild} context + * The extension context which has registered the user script. + * @param {string} scriptId + * An unique id that represents the registered user script + * (generated and used internally to identify it across the different processes). + */ +class UserScriptChild { + constructor({ context, scriptId, onScriptUnregister }) { + this.context = context; + this.scriptId = scriptId; + this.onScriptUnregister = onScriptUnregister; + this.unregistered = false; + } + + async unregister() { + if (this.unregistered) { + throw new ExtensionError("User script already unregistered"); + } + + this.unregistered = true; + + await this.context.childManager.callParentAsyncFunction( + "userScripts.unregister", + [this.scriptId] + ); + + this.context = null; + + this.onScriptUnregister(); + } + + api() { + const { context } = this; + + // Returns the RegisteredUserScript API object. + return { + unregister: () => { + return context.wrapPromise(this.unregister()); + }, + }; + } +} + +this.userScripts = class extends ExtensionAPI { + getAPI(context) { + // Cache of the script code already converted into blob urls: + // Map + const blobURLsByHash = new Map(); + + // Keep track of the userScript that are sharing the same blob urls, + // so that we can revoke any blob url that is not used by a registered + // userScripts: + // Map> + const userScriptsByBlobURL = new DefaultMap(() => new Set()); + + function revokeBlobURLs(scriptId, options) { + let revokedUrls = new Set(); + + for (let url of options.js) { + if (userScriptsByBlobURL.has(url)) { + let scriptIds = userScriptsByBlobURL.get(url); + scriptIds.delete(scriptId); + + if (scriptIds.size === 0) { + revokedUrls.add(url); + userScriptsByBlobURL.delete(url); + context.cloneScope.URL.revokeObjectURL(url); + } + } + } + + // Remove all the removed urls from the map of known computed hashes. + for (let [hash, url] of blobURLsByHash) { + if (revokedUrls.has(url)) { + blobURLsByHash.delete(hash); + } + } + } + + // Convert a script code string into a blob URL (and use a cached one + // if the script hash is already associated to a blob URL). + const getBlobURL = async (text, scriptId) => { + // Compute the hash of the js code string and reuse the blob url if we already have + // for the same hash. + const buffer = await crypto.subtle.digest( + "SHA-1", + new TextEncoder().encode(text) + ); + const hash = String.fromCharCode(...new Uint16Array(buffer)); + + let blobURL = blobURLsByHash.get(hash); + + if (blobURL) { + userScriptsByBlobURL.get(blobURL).add(scriptId); + return blobURL; + } + + const blob = new context.cloneScope.Blob([text], { + type: "text/javascript", + }); + blobURL = context.cloneScope.URL.createObjectURL(blob); + + // Start to track this blob URL. + userScriptsByBlobURL.get(blobURL).add(scriptId); + + blobURLsByHash.set(hash, blobURL); + + return blobURL; + }; + + function convertToAPIObject(scriptId, options) { + const registeredScript = new UserScriptChild({ + context, + scriptId, + onScriptUnregister: () => revokeBlobURLs(scriptId, options), + }); + + const scriptAPI = Cu.cloneInto( + registeredScript.api(), + context.cloneScope, + { cloneFunctions: true } + ); + return scriptAPI; + } + + // Revoke all the created blob urls once the context is destroyed. + context.callOnClose({ + close() { + if (!context.cloneScope) { + return; + } + + for (let blobURL of blobURLsByHash.values()) { + context.cloneScope.URL.revokeObjectURL(blobURL); + } + }, + }); + + return { + userScripts: { + register(options) { + if (!userScriptsEnabled) { + throw new ExtensionError(USERSCRIPT_DISABLED_ERRORMSG); + } + + let scriptId = getUniqueId(); + return context.cloneScope.Promise.resolve().then(async () => { + options.scriptId = scriptId; + options.js = await Promise.all( + options.js.map(js => { + return js.file || getBlobURL(js.code, scriptId); + }) + ); + + await context.childManager.callParentAsyncFunction( + "userScripts.register", + [options] + ); + + return convertToAPIObject(scriptId, options); + }); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/child/ext-webRequest.js b/toolkit/components/extensions/child/ext-webRequest.js new file mode 100644 index 0000000000..49bdd3f232 --- /dev/null +++ b/toolkit/components/extensions/child/ext-webRequest.js @@ -0,0 +1,119 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +var { ExtensionError } = ExtensionUtils; + +this.webRequest = class extends ExtensionAPI { + STREAM_FILTER_INACTIVE_STATUSES = ["closed", "disconnected", "failed"]; + + hasActiveStreamFilter(filtersWeakSet) { + const iter = ChromeUtils.nondeterministicGetWeakSetKeys(filtersWeakSet); + for (let filter of iter) { + if (!this.STREAM_FILTER_INACTIVE_STATUSES.includes(filter.status)) { + return true; + } + } + return false; + } + + watchStreamFilterSuspendCancel({ + context, + filters, + onSuspend, + onSuspendCanceled, + }) { + if ( + !context.isBackgroundContext || + context.extension.persistentBackground !== false + ) { + return; + } + + const { extension } = context; + const cancelSuspendOnActiveStreamFilter = () => + this.hasActiveStreamFilter(filters); + context.callOnClose({ + close() { + extension.off( + "internal:stream-filter-suspend-cancel", + cancelSuspendOnActiveStreamFilter + ); + extension.off("background-script-suspend", onSuspend); + extension.off("background-script-suspend-canceled", onSuspend); + }, + }); + extension.on( + "internal:stream-filter-suspend-cancel", + cancelSuspendOnActiveStreamFilter + ); + extension.on("background-script-suspend", onSuspend); + extension.on("background-script-suspend-canceled", onSuspendCanceled); + } + + getAPI(context) { + let filters = new WeakSet(); + + context.callOnClose({ + close() { + for (let filter of ChromeUtils.nondeterministicGetWeakSetKeys( + filters + )) { + try { + filter.disconnect(); + } catch (e) { + // Ignore. + } + } + }, + }); + + let isSuspending = false; + this.watchStreamFilterSuspendCancel({ + context, + filters, + onSuspend: () => (isSuspending = true), + onSuspendCanceled: () => (isSuspending = false), + }); + + function filterResponseData(requestId) { + if (isSuspending) { + throw new ExtensionError( + "filterResponseData method calls forbidden while background extension global is suspending" + ); + } + requestId = parseInt(requestId, 10); + + let streamFilter = context.cloneScope.StreamFilter.create( + requestId, + context.extension.id + ); + + filters.add(streamFilter); + return streamFilter; + } + + const webRequest = {}; + + // For extensions with manifest_version >= 3, an additional webRequestFilterResponse permission + // is required to get access to the webRequest.filterResponseData API method. + if ( + context.extension.manifestVersion < 3 || + context.extension.hasPermission("webRequestFilterResponse") + ) { + webRequest.filterResponseData = filterResponseData; + } else { + webRequest.filterResponseData = () => { + throw new ExtensionError( + 'Missing required "webRequestFilterResponse" permission' + ); + }; + } + + return { webRequest }; + } +}; diff --git a/toolkit/components/extensions/components.conf b/toolkit/components/extensions/components.conf new file mode 100644 index 0000000000..0b6461f13d --- /dev/null +++ b/toolkit/components/extensions/components.conf @@ -0,0 +1,16 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +Classes = [ + { + 'cid': '{db82286d-d649-47fb-8599-ba31673a58c5}', + 'contract_ids': ['@mozilla.org/extensions/child;1'], + 'type': 'mozilla::extensions::ExtensionsChild', + 'constructor': 'mozilla::extensions::ExtensionsChild::GetSingleton', + 'headers': ['mozilla/extensions/ExtensionsChild.h'], + 'categories': {'app-startup': 'ExtensionsChild'}, + }, +] diff --git a/toolkit/components/extensions/docs/background.rst b/toolkit/components/extensions/docs/background.rst new file mode 100644 index 0000000000..5d5dcd06b9 --- /dev/null +++ b/toolkit/components/extensions/docs/background.rst @@ -0,0 +1,133 @@ +Background +========== + +WebExtensions run in a sandboxed environment much like regular web content. +The purpose of extensions is to enhance the browser in a way that +regular content cannot -- WebExtensions APIs bridge this gap by exposing +browser features to extensions in a way preserves safety, reliability, +and performance. +The implementation of a WebExtension API runs with +:doc:`chrome privileges `. +Browser internals are accessed using +:ref:`XPCOM` +or :doc:`ChromeOnly WebIDL features `. + +The rest of this documentation covers how API implementations interact +with the implementation of WebExtensions. +To expose some browser feature to WebExtensions, the first step is +to design the API. Some high-level principles for API design +are documented on the Mozilla wiki: + +- `Vision for WebExtensions `_ +- `API Policies `_ +- `Process for creating new APIs `_ + +Javascript APIs +--------------- +Many WebExtension APIs are accessed directly from extensions through +Javascript. Functions are the most common type of object to expose, +though some extensions expose properties of primitive Javascript types +(e.g., constants). +Regardless of the exact method by which something is exposed, +there are a few important considerations when designing part of an API +that is accessible from Javascript: + +- **Namespace**: + Everything provided to extensions is exposed as part of a global object + called ``browser``. For compatibility with Google Chrome, many of these + features are also exposed on a global object called ``chrome``. + Functions and other objects are not exposed directly as properties on + ``browser``, they are organized into *namespaces*, which appear as + properties on ``browser``. For example, the + `tabs API `_ + uses a namespace called ``tabs``, so all its functions and other + properties appear on the object ``browser.tabs``. + For a new API that provides features via Javascript, the usual practice + is to create a new namespace with a concise but descriptive name. + +- **Environments**: + There are several different types of Javascript environments in which + extension code can execute: extension pages, content scripts, proxy + scripts, and devtools pages. + Extension pages include the background page, popups, and content pages + accessed via |getURL|_. + When creating a new Javascript feature the designer must choose + in which of these environments the feature will be available. + Most Javascript features are available in extension pages, + other environments have limited sets of API features available. + +.. |getURL| replace:: ``browser.runtime.getURL()`` +.. _getURL: https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/runtime/getURL + +- **Permissions**: + Many Javascript features are only present for extensions that + include an appropriate permission in the manifest. + The guidelines for when an API feature requires a permission are + described in (*citation needed*). + +The specific types of features that can be exposed via Javascript are: + +- **Functions**: + A function callable from Javascript is perhaps the most commonly + used feature in WebExtension APIs. + New API functions are asynchronous, returning a + `Promise `_. Even functions that do not return a result + use Promises so that errors can be indicated asynchronously + via a rejected Promise as opposed to a synchronously thrown Error. + This is due to the fact that extensions run in a child process and + many API functions require communication with the main process. + If an API function that needs to communicate in this way returned a + synchronous result, then all Javascript execution in the child + process would need to be paused until a response from the main process + was received. Even if a function could be implemented synchronously + within a child process, the standard practice is to make it + asynchronous so as not to constrain the implementation of the underlying + browser feature and make it impossible to move functionality out of the + child process. + Another consequence of functions using inter-process communication is + that the parameters to a function and its return value must all be + simple data types that can be sent between processes using the + `structured clone algorithm `_. + +- **Events**: + Events complement functions (which allow an extension to call into + an API) by allowing an event within the browser to invoke a callback + in the extension. + Any time an API requires an extension to pass a callback function that + gets invoked some arbitrary number of times, that API method should be + defined as an event. + +Manifest Keys +------------- +In addition to providing functionality via Javascript, WebExtension APIs +can also take actions based on the contents of particular properties +in an extension's manifest (or even just the presence of a particular +property). +Manifest entries are used for features in which an extension specifies +some static information that is used when an extension is installed or +when it starts up (i.e., before it has the chance to run any code to use +a Javascript API). +An API may handle a manifest key and implement Javascript functionality, +see the +`browser action `_ +API for an example. + +Other Considerations +-------------------- +In addition to the guidelines outlined above, +there are some other considerations when designing and implementing +a WebExtension API: + +- **Cleanup**: A badly written WebExtension should not be able to permanently + leak any resources. In particular, any action from an extension that + causes a resource to be allocated within the browser should be + automatically cleaned up when the extension is disabled or uninstalled. + This is described in more detail in the section on :ref:`lifecycle`. + +- **Performance**: A new WebExtension API should not add any new overhead + to the browser when the API is not used. That is, the implementation + of the API should not be loaded at all unless it is actively used by + an extension. In addition, initialization should be delayed when + possible -- extensions ared started relatively early in the browser + startup process so any unnecessary work done during extension startup + contributes directly to sluggish browser startup. diff --git a/toolkit/components/extensions/docs/basics.rst b/toolkit/components/extensions/docs/basics.rst new file mode 100644 index 0000000000..35d61561e2 --- /dev/null +++ b/toolkit/components/extensions/docs/basics.rst @@ -0,0 +1,275 @@ +.. _basics: + +API Implementation Basics +========================= +This page describes some of the pieces involved when creating +WebExtensions APIs. Detailed documentation about how these pieces work +together to build specific features is in the next section. + +The API Schema +-------------- +As described previously, a WebExtension runs in a sandboxed environment +but the implementation of a WebExtensions API runs with full chrome +privileges. API implementations do not directly interact with +extensions' Javascript environments, that is handled by the WebExtensions +framework. Each API includes a schema that describes all the functions, +events, and other properties that the API might inject into an +extension's Javascript environment. +Among other things, the schema specifies the namespace into which +an API should be injected, what permissions (if any) are required to +use it, and in which contexts (e.g., extension pages, content scripts, etc) +it should be available. The WebExtensions framework reads this schema +and takes care of injecting the right objects into each extension +Javascript environment. + +API schemas are written in JSON and are based on +`JSON Schema `_ with some extensions to describe +API functions and events. +The next section describes the format of the schema in detail. + +The ExtensionAPI class +---------------------- +Every WebExtensions API is represented by an instance of the Javascript +`ExtensionAPI `_ class. +An instance of its API class is created every time an extension that has +access to the API is enabled. Instances of this class contain the +implementations of functions and events that are exposed to extensions, +and they also contain code for handling manifest keys as well as other +part of the extension lifecycle (e.g., updates, uninstalls, etc.) +The details of this class are covered in a subsequent section, for now the +important point is that this class contains all the actual code that +backs a particular WebExtensions API. + +Built-in versus Experimental APIs +--------------------------------- +A WebExtensions API can be built directly into the browser or it can be +contained in a special type of extension called a privileged extension +that defines a WebExtensions Experiment (i.e. experimental APIs). +The API schema and the ExtensionAPI class are written in the same way +regardless of how the API will be delivered, the rest of this section +explains how to package a new API using these methods. + +Adding a built-in API +--------------------- +Built-in WebExtensions APIs are loaded lazily. That is, the schema and +accompanying code are not actually loaded and interpreted until an +extension that uses the API is activated. +To actually register the API with the WebExtensions framework, an entry +must be added to the list of WebExtensions modules in one of the following +files: + +- ``toolkit/components/extensions/ext-toolkit.json`` +- ``browser/components/extensions/ext-browser.json`` +- ``mobile/android/components/extensions/ext-android.json`` + +Here is a sample fragment for a new API: + +.. code-block:: js + + "myapi": { + "schema": "chrome://extensions/content/schemas/myapi.json", + "url": "chrome://extensions/content/ext-myapi.js", + "paths": [ + ["myapi"], + ["anothernamespace", "subproperty"] + ], + "scopes": ["addon_parent"], + "permissions": ["myapi"], + "manifest": ["myapi_key"], + "events": ["update", "uninstall"] + } + +The ``schema`` and ``url`` properties are simply URLs for the API schema +and the code implementing the API. The ``chrome:`` URLs in the example above +are typically created by adding entries to ``jar.mn`` in the mozilla-central +directory where the API implementation is kept. The standard locations for +API implementations are: + +- ``toolkit/components/extensions``: This is where APIs that work in both + the desktop and mobile versions of Firefox (as well as potentially any + other applications built on Gecko) should go +- ``browser/components/extensions``: APIs that are only supported on + Firefox for the desktop. +- ``mobile/android/components/extensions``: APIs that are only supported + on Firefox for Android. + +Within the appropriate extensions directory, the convention is that the +API schema is in a file called ``schemas/name.json`` (where *name* is +the name of the API, typically the same as its namespace if it has +Javascript visible features). The code for the ExtensionAPI class is put +in a file called ``ext-name.js``. If the API has code that runs in a +child process, that is conventionally put in a file called ``ext-c-name.js``. + +The remaining properties specify when an API should be loaded. +The ``paths``, ``scopes``, and ``permissions`` properties together +cause an API to be loaded when Javascript code in an extension references +something beneath the ``browser`` global object that is part of the API. +The ``paths`` property is an array of paths where each individual path is +also an array of property names. In the example above, the sample API will +be loaded if an extension references either ``browser.myapi`` or +``browser.anothernamespace.subproperty``. + +A reference to a property beneath ``browser`` only causes the API to be +loaded if it occurs within a scope listed in the ``scopes`` property. +A scope corresponds to the combination of a Javascript environment +(e.g., extension pages, content scripts, etc) and the process in which the +API code should run (which is either the main/parent process, or a +content/child process). +Valid ``scopes`` are: + +- ``"addon_parent"``, ``"addon_child``: Extension pages + +- ``"content_parent"``, ``"content_child``: Content scripts + +- ``"devtools_parent"``, ``"devtools_child"``: Devtools pages + +The distinction between the ``_parent`` and ``_child`` scopes will be +explained in further detail in following sections. + +A reference to a property only causes the API to be loaded if the +extension referencing the property also has all the permissions listed +in the ``permissions`` property. + +A WebExtensions API that is controlled by a manifest key can also be loaded +when an extension that includes the relevant manifest key is activated. +This is specified by the ``manifest`` property, which lists any manifest keys +that should cause the API to be loaded. + +Finally, APIs can be loaded based on other events in the WebExtension +lifecycle. These are listed in the ``events`` property and described in +more detail in :ref:`lifecycle`. + +Adding Experimental APIs in Privileged Extensions +------------------------------------------------- + +A new API may also be implemented within a privileged extension. An API +implemented this way is called a WebExtensions Experiment (or simply an +Experimental API). Experiments can be useful when actively developing a +new API, as they do not require building Firefox locally. These extensions +may be installed temporarily via ``about:debugging`` or, on browser that +support it (current Nightly and Developer Edition), by setting the preference +``xpinstall.signatures.required`` to ``false``. You may also set the +preference ``extensions.experiments.enabled`` to ``true`` to install the +addon normally and test across restart. + +.. note:: + Out-of-tree privileged extensions cannot be signed by addons.mozilla.org. + A different pipeline is used to sign them with a privileged certificate. + You'll find more information in the `xpi-manifest repository on GitHub `_. + +Experimental APIs have a few limitations compared with built-in APIs: + +- Experimental APIs can (currently) only be exposed to extension pages, + not to devtools pages or to content scripts. +- Experimental APIs cannot handle manifest keys (since the extension manifest + needs to be parsed and validated before experimental APIs are loaded). +- Experimental APIs cannot use the static ``"update"`` and ``"uninstall"`` + lifecycle events (since in general those may occur when an affected + extension is not active or installed). + +Experimental APIs are declared in the ``experiment_apis`` property in a +WebExtension's ``manifest.json`` file. For example: + +.. code-block:: js + + { + "manifest_version": 2, + "name": "Extension containing an experimental API", + "experiment_apis": { + "apiname": { + "schema": "schema.json", + "parent": { + "scopes": ["addon_parent"], + "paths": [["myapi"]], + "script": "implementation.js" + }, + + "child": { + "scopes": ["addon_child"], + "paths": [["myapi"]], + "script": "child-implementation.js" + } + } + } + } + +This is essentially the same information required for built-in APIs, +just organized differently. The ``schema`` property is a relative path +to a file inside the extension containing the API schema. The actual +implementation details for the parent process and for child processes +are defined in the ``parent`` and ``child`` properties of the API +definition respectively. Inside these sections, the ``scope`` and ``paths`` +properties have the same meaning as those properties in the definition +of a built-in API (though see the note above about limitations; the +only currently valid values for ``scope`` are ``"addon_parent"`` and +``"addon_child"``). The ``script`` property is a relative path to a file +inside the extension containing the implementation of the API. + +The extension that includes an experiment defined in this way automatically +gets access to the experimental API. An extension may also use an +experimental API implemented in a different extension by including the +string ``experiments.name`` in the ``permissions``` property in its +``manifest.json`` file. In this case, the string name must be replace by +the name of the API from the extension that defined it (e.g., ``apiname`` +in the example above. + +Globals available in the API scripts global +------------------------------------------- + +The API scripts aren't loaded as an JSM and so: + +- they are not fully isolated from each other (and they are going to be + lazy loaded when the extension does use them for the first time) and + be executed in a per-process shared global scope) +- the experimental APIs embedded in privileged extensions are executed + in a per-extension global (separate from the one used for the built-in APIs) + +The global scope where the API scripts are executed is pre-populated with +some useful globals: + +- ``AppConstants`` +- ``console`` +- ``CC``, ``Ci``, ``Cr`` and ``Cu`` +- ``ChromeWorker`` +- ``extensions``, ``ExtensionAPI``, ``ExtensionCommon`` and ``ExtensionUtils`` +- ``global`` +- ``MatchGlob``, ``MatchPattern`` and ``MatchPatternSet`` +- ``Services`` +- ``StructuredCloneHolder`` +- ``XPCOMUtils`` + +For a more complete and updated list of the globals available by default in +all API scripts look to the following source: + +- `SchemaAPIManager _createExtGlobal method `_ +- Only available in the parent Firefox process: + `toolkit/components/extensions/parent/ext-toolkit.js `_ +- Only available in the child Firefox process: + `toolkit/components/extensions/child/ext-toolkit.js `_ +- Only available in the Desktop builds: + `browser/components/extensions/parent/ext-browser.js `_ +- Only available in the Android builds: + `mobile/android/components/extensions/ext-android.js `_ + +.. warning:: + The extension API authors should never redefine these globals to avoid introducing potential + conflicts between API scripts (e.g. see `Bug 1697404 comment 3 `_ + and `Bug 1697404 comment 4 `_). + +WebIDL Bindings +--------------- + +In ``manifest_version: 3`` the extension will be able to declare a background service worker +instead of a background page, and the existing WebExtensions API bindings can't be injected into this +new extension global, because it lives off the main thread. + +To expose WebExtensions API bindings to the WebExtensions ``background.service_worker`` global +we are in the process of generating new WebIDL bindings for the WebExtensions API. + +An high level view of the architecture and a more in depth details about the architecture process +to create or modify WebIDL bindings for the WebExtensions API can be found here: + +.. toctree:: + :maxdepth: 2 + + webidl_bindings diff --git a/toolkit/components/extensions/docs/events.rst b/toolkit/components/extensions/docs/events.rst new file mode 100644 index 0000000000..d494155ffc --- /dev/null +++ b/toolkit/components/extensions/docs/events.rst @@ -0,0 +1,609 @@ +Implementing an event +===================== +Like a function, an event requires a definition in the schema and +an implementation in Javascript inside an instance of ExtensionAPI. + +Declaring an event in the API schema +------------------------------------ +The definition for a simple event looks like this: + +.. code-block:: json + + [ + { + "namespace": "myapi", + "events": [ + { + "name": "onSomething", + "type": "function", + "description": "Description of the event", + "parameters": [ + { + "name": "param1", + "description": "Description of the first callback parameter", + "type": "number" + } + ] + } + ] + } + ] + +This fragment defines an event that is used from an extension with +code such as: + +.. code-block:: js + + browser.myapi.onSomething.addListener(param1 => { + console.log(`Something happened: ${param1}`); + }); + +Note that the schema syntax looks similar to that for a function, +but for an event, the ``parameters`` property specifies the arguments +that will be passed to a listener. + +Implementing an event +--------------------- +Just like with functions, defining an event in the schema causes +wrappers to be automatically created and exposed to an extensions' +appropriate Javascript contexts. +An event appears to an extension as an object with three standard +function properties: ``addListener()``, ``removeListener()``, +and ``hasListener()``. +Also like functions, if an API defines an event but does not implement +it in a child process, the wrapper in the child process effectively +proxies these calls to the implementation in the main process. + +A helper class called +`EventManager `_ makes implementing +events relatively simple. A simple event implementation looks like: + +.. code-block:: js + + this.myapi = class extends ExtensionAPI { + getAPI(context) { + return { + myapi: { + onSomething: new EventManager({ + context, + name: "myapi.onSomething", + register: fire => { + const callback = value => { + fire.async(value); + }; + RegisterSomeInternalCallback(callback); + return () => { + UnregisterInternalCallback(callback); + }; + } + }).api(), + } + } + } + } + +The ``EventManager`` class is usually just used directly as in this example. +The first argument to the constructor is an ``ExtensionContext`` instance, +typically just the object passed to the API's ``getAPI()`` function. +The second argument is a name, it is used only for debugging. +The third argument is the important piece, it is a function that is called +the first time a listener is added for this event. +This function is passed an object (``fire`` in the example) that is used to +invoke the extension's listener whenever the event occurs. The ``fire`` +object has several different methods for invoking listeners, but for +events implemented in the main process, the only valid method is +``async()`` which executes the listener asynchronously. + +The event setup function (the function passed to the ``EventManager`` +constructor) must return a cleanup function, +which will be called when the listener is removed either explicitly +by the extension by calling ``removeListener()`` or implicitly when +the extension Javascript context from which the listener was added is destroyed. + +In this example, ``RegisterSomeInternalCallback()`` and +``UnregisterInternalCallback()`` represent methods for listening for +some internal browser event from chrome privileged code. This is +typically something like adding an observer using ``Services.obs`` or +attaching a listener to an ``EventEmitter``. + +After constructing an instance of ``EventManager``, its ``api()`` method +returns an object with with ``addListener()``, ``removeListener()``, and +``hasListener()`` methods. This is the standard extension event interface, +this object is suitable for returning from the extension's +``getAPI()`` method as in the example above. + +Handling extra arguments to addListener() +----------------------------------------- +The standard ``addListener()`` method for events may accept optional +addition parameters to allow extra information to be passed when registering +an event listener. One common application of this parameter is for filtering, +so that extensions that only care about a small subset of the instances of +some event can avoid the overhead of receiving the ones they don't care about. + +Extra parameters to ``addListener()`` are defined in the schema with the +the ``extraParameters`` property. For example: + +.. code-block:: json + + [ + { + "namespace": "myapi", + "events": [ + { + "name": "onSomething", + "type": "function", + "description": "Description of the event", + "parameters": [ + { + "name": "param1", + "description": "Description of the first callback parameter", + "type": "number" + } + ], + "extraParameters": [ + { + "name": "minValue", + "description": "Only call the listener for values of param1 at least as large as this value.", + "type": "number" + } + ] + } + ] + } + ] + +Extra parameters defined in this way are passed to the event setup +function (the last parameter to the ``EventManager`` constructor. +For example, extending our example above: + +.. code-block:: js + + this.myapi = class extends ExtensionAPI { + getAPI(context) { + return { + myapi: { + onSomething: new EventManager({ + context, + module: "myapi", + event: "onSomething", + register: (fire, minValue) => { + const callback = value => { + if (value >= minValue) { + fire.async(value); + } + }; + RegisterSomeInternalCallback(callback); + return () => { + UnregisterInternalCallback(callback); + }; + } + }).api() + } + } + } + } + +Handling listener return values +------------------------------- +Some event APIs allow extensions to affect event handling in some way +by returning values from event listeners that are processed by the API. +This can be defined in the schema with the ``returns`` property: + +.. code-block:: json + + [ + { + "namespace": "myapi", + "events": [ + { + "name": "onSomething", + "type": "function", + "description": "Description of the event", + "parameters": [ + { + "name": "param1", + "description": "Description of the first callback parameter", + "type": "number" + } + ], + "returns": { + "type": "string", + "description": "Description of how the listener return value is processed." + } + } + ] + } + ] + +And the implementation of the event uses the return value from ``fire.async()`` +which is a Promise that resolves to the listener's return value: + +.. code-block:: js + + this.myapi = class extends ExtensionAPI { + getAPI(context) { + return { + myapi: { + onSomething: new EventManager({ + context, + module: "myapi", + event: "onSomething", + register: fire => { + const callback = async (value) => { + let rv = await fire.async(value); + log(`The onSomething listener returned the string ${rv}`); + }; + RegisterSomeInternalCallback(callback); + return () => { + UnregisterInternalCallback(callback); + }; + } + }).api() + } + } + } + } + +Note that the schema ``returns`` definition is optional and serves only +for documentation. That is, ``fire.async()`` always returns a Promise +that resolves to the listener return value, the implementation of an +event can just ignore this Promise if it doesn't care about the return value. + +Implementing an event in the child process +------------------------------------------ +The reasons for implementing events in the child process are similar to +the reasons for implementing functions in the child process: + +- Listeners for the event return a value that the API implementation must + act on synchronously. + +- Either ``addListener()`` or the listener function has one or more + parameters of a type that cannot be sent between processes. + +- The implementation of the event interacts with code that is only + accessible from a child process. + +- The event can be implemented substantially more efficiently in a + child process. + +The process for implementing an event in the child process is the same +as for functions -- simply implement the event in an ExtensionAPI subclass +that is loaded in a child process. And just as a function in a child +process can call a function in the main process with +`callParentAsyncFunction()`, events in a child process may subscribe to +events implemented in the main process with a similar `getParentEvent()`. +For example, the automatically generated event proxy in a child process +could be written explicitly as: + +.. code-block:: js + + this.myapi = class extends ExtensionAPI { + getAPI(context) { + return { + myapi: { + onSomething: new EventManager( + context, + name: "myapi.onSomething", + register: fire => { + const listener = (value) => { + fire.async(value); + }; + + let parentEvent = context.childManager.getParentEvent("myapi.onSomething"); + parent.addListener(listener); + return () => { + parent.removeListener(listener); + }; + } + }).api() + } + } + } + } + +Events implemented in a child process have some additional methods available +to dispatch listeners: + +- ``fire.sync()`` This runs the listener synchronously and returns the + value returned by the listener + +- ``fire.raw()`` This runs the listener synchronously without cloning + the listener arguments into the extension's Javascript compartment. + This is used as a performance optimization, it should not be used + unless you have a detailed understanding of Javascript compartments + and cross-compartment wrappers. + +Event Listeners Persistence +--------------------------- + +Event listeners are persisted in some circumstances. Persisted event listeners can either +block startup, and/or cause an Event Page or Background Service Worker to be started. + +The event listener must be registered synchronously in the top level scope +of the background. Event listeners registered later, or asynchronously, are +not persisted. + +Currently only WebRequestBlocking and Proxy events are able to block +at startup, causing an addon to start earlier in Firefox startup. Whether +a module can block startup is defined by a ``startupBlocking`` flag in +the module definition files (``ext-toolkit.json`` or ``ext-browser.json``). +As well, these are the only events persisted for persistent background scripts. + +Events implemented only in a child process, without a parent process counterpart, +cannot be persisted. + +Persisted and Primed Event Listeners +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +In terms of terminology: + +- **Persisted Event Listener** is the set of data (in particular API module, API event name + and the parameters passed along with addListener call if any) related to an event listener + that has been registered by an Event Page (or Background Service Worker) in a previous run + and being stored in the StartupCache data + +- **Primed Event Listener** is a "placeholder" event listener created, from the **Persisted Event Listener** + data found in the StartupCache, while the Event Page (or Background Service Worker) is not running + (either not started yet or suspended after the idle timeout was hit) + +ExtensionAPIPersistent and PERSISTENT_EVENTS +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Most of the WebExtensions APIs promise some API events, and it is likely that most of those events are also +expected to be waking up the Event Page (or Background Service Worker) when emitted while the background +extension context has not been started yet (or it was suspended after the idle timeout was hit). + +As part of implementing a WebExtensions API that is meant to persist all or some of its API event +listeners: + +- the WebExtensions API namespace class should extend ``ExtensionAPIPersistent`` (instead of extending + the ``ExtensionAPI`` class) + +- the WebExtensions API namespace should have a ``PERSISTENT_EVENTS`` property, which is expected to be + set to an object defining methods. Each method should be named after the related API event name, which + are going to be called internally: + + - while the extension Event Page (or Background Service Worker) isn't running (either never started yet + or suspended after the idle timeout). These methods are called by the WebExtensions internals to + create placeholder API event listeners in the parent process for each of the API event listeners + persisted for that extension. These placeholder listeners are internally referred to as + ``primed listeners``). + + - while the extension Event Page (or Background Service Worker) is running (as well as for any other + extension context types they may have been created for the extension). These methods are called by the + WebExtensions internals to create the parent process callback that will be responsible for + forwarding the API events to the extension callbacks in the child processes. + +- in the ``getAPI`` method. For all the API namespace properties that represent API events returned by this method, + the ``EventManager`` instances created for each of the API events that is expected to persist its listeners + should include following options: + + - ``module``, to be set to the API module name as listed in ``"ext-toolkit.json"`` / ``"ext-browser.json"`` + / ``"ext-android.json"`` (which, in most cases, is the same as the API namespace name string). + - ``event``, to be set to the API event name string. + - ``extensionApi``, to be set to the ``ExtensionAPIPersistent`` class instance. + +Taking a look to some of the patches landed to introduce API event listener persistency on some of the existing +API as part of introducing support for the Event Page may also be useful: + +- Bug-1748546_ ported the browserAction and pageAction API namespace implementations to + ``ExtensionAPIPersistent`` and, in particular, the changes applied to: + + - ext-browserAction.js: https://hg.mozilla.org/integration/autoland/rev/08a3eaa8bce7 + - ext-pageAction.js: https://hg.mozilla.org/integration/autoland/rev/ed616e2e0abb + +.. _Bug-1748546: https://bugzilla.mozilla.org/show_bug.cgi?id=1748546 + +Follows an example of what has been described previously in a code snippet form: + +.. code-block:: js + + this.myApiName = class extends ExtensionAPIPersistent { + PERSISTENT_EVENTS = { + // @param {object} options + // @param {object} options.fire + // @param {function} options.fire.async + // @param {function} options.fire.sync + // @param {function} options.fire.raw + // For primed listeners `fire.async`/`fire.sync`/`fire.raw` will + // collect the pending events to be send to the background context + // and implicitly wake up the background context (Event Page or + // Background Service Worker), or forward the event right away if + // the background context is running. + // @param {function} [options.fire.wakeup = undefined] + // For primed listeners, the `fire` object also provide a `wakeup` method + // which can be used by the primed listener to explicitly `wakeup` the + // background context (Event Page or Background Service Worker) and wait for + // it to be running (by awaiting on the Promise returned by wakeup to be + // resolved). + // @param {ProxyContextParent} [options.context=undefined] + // This property is expected to be undefined for primed listeners (which + // are created while the background extension context does not exist) and + // to be set to a ProxyContextParent instance (the same got by the getAPI + // method) when the method is called for a listener registered by a + // running extension context. + // + // @param {object} [apiEventsParams=undefined] + // The additional addListener parameter if any (some API events are allowing + // the extensions to pass some parameters along with the extension callback). + onMyEventName({ context, fire }, apiEventParams = undefined) { + const listener = (...) { + // Wake up the EventPage (or Background ServiceWorker). + if (fire.wakeup) { + await fire.wakeup(); + } + + fire.async(...); + } + + // Subscribe a listener to an internal observer or event which will be notified + // when we need to call fire to either send the event to an extension context + // already running or wake up a suspended event page and accumulate the events + // to be fired once the extension context is running again and a callback registered + // back (which will be used to convert the primed listener created while + // the non persistent background extension context was not running yet) + ... + return { + unregister() { + // Unsubscribe a listener from an internal observer or event. + ... + } + convert(fireToExtensionCallback) { + // Convert gets called once the primed API event listener, + // created while the extension background context has been + // suspended, is being converted to a parent process API + // event listener callback that is responsible for forwarding the + // events to the child processes. + // + // The `fireToExtensionCallback` parameter is going to be the + // one that will emit the event to the extension callback (while + // the one got from the API event registrar method may be the one + // that is collecting the events to emit up until the background + // context got started up again). + fire = fireToExtensionCallback; + }, + }; + }, + ... + }; + + getAPI(context) { + ... + return { + myAPIName: { + ... + onMyEventName: new EventManager({ + context, + // NOTE: module is expected to be the API module name as listed in + // ext-toolkit.json / ext-browser.json / ext-android.json. + module: "myAPIName", + event: "onMyEventNAme", + extensionApi: this, + }), + }, + }; + } + }; + +Testing Persisted API Event Listeners +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- ``extension.terminateBackground()`` / ``extension.terminateBackground({ disableResetIdleForTest: true})``: + + - The wrapper object returned by ``ExtensionTestUtils.loadExtension`` provides + a ``terminateBackground`` method which can be used to simulate an idle timeout, + by explicitly triggering the same idle timeout suspend logic handling the idle timeout. + - This method also accept an optional parameter, if set to ``{ disableResetIdleForTest: true}`` + will forcefully suspend the background extension context and ignore all the + conditions that would reset the idle timeout due to some work still pending + (e.g. a ``NativeMessaging``'s ``Port`` still open, a ``StreamFilter`` instance + still active or a ``Promise`` from an API event listener call not yet resolved) + +- ``ExtensionTestUtils.testAssertions.assertPersistentListeners``: + + - This test assertion helper can be used to more easily assert what should + be the persisted state of a given API event (e.g. assert it to not be + persisted, or to be persisted and/or primed) + +.. code-block:: js + + assertPersistentListeners(extension, "browserAction", "onClicked", { + primed: false, + }); + await extension.terminateBackground(); + assertPersistentListeners(extension, "browserAction", "onClicked", { + primed: true, + }); + +- ``extensions.background.idle.timeout`` preference determines how long to wait + (between API events being notified to the extension event page) before considering + the Event Page in the idle state and suspend it, in some xpcshell test this pref + may be set to 0 to reduce the amount of time the test will have to wait for the + Event Page to be suspended automatically + +- ``extension.eventPage.enabled`` pref is responsible for enabling/disabling + Event Page support for manifest_version 2 extension, technically it is + now set to ``true`` on all channels, but it would still be worth flipping it + to ``true`` explicitly in tests that are meant to cover Event Page behaviors + for manifest_version 2 test extension until the pref is completely removed + (mainly to make sure that if the pref would need to be flipped to false + for any reason, the tests will still be passing) + +Persisted Event listeners internals +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The ``ExtensionAPIPersistent`` class provides a way to quickly introduce API event +listener persistency to a new WebExtensions API, and reduce the number of code +duplication, the following section provide some more details about what the +abstractions are doing internally in practice. + +WebExtensions APIs classes that extend the ``ExtensionAPIPersistent`` base class +are still able to support non persisted listeners along with persisted ones +(e.g. events that are persisting the listeners registered from an event page are +already not persisting listeners registered from other extension contexts) +and can mix persisted and non-persisted events. + +As an example in ``toolkit/components/extensions/parent/ext-runtime.js``` the two +events ``onSuspend`` and ``onSuspendCanceled`` are expected to be never persisted +nor primed (even for an event page) and so their ``EventManager`` instances +receive the following options: + +- a ``register`` callback (instead of the one part of ``PERSISTED_EVENTS``) +- a ``name`` string property (instead of the two separate ``module`` and ``event`` + string properties that are used for ``EventManager`` instances from persisted + ones +- no ``extensionApi`` property (because that is only needed for events that are + expected to persist event page listeners) + +In practice ``ExtensionAPIPersistent`` extends the ``ExtensionAPI`` class to provide +a generic ``primeListeners`` method, which is the method responsible for priming +a persisted listener when the event page has been suspended or not started yet. + +The ``primeListener`` method is expected to return an object with an ``unregister`` +and ``convert`` method, while the ``register`` callback passed to the ``EventManager`` +constructor is expected to return the ``unregister`` method. + +.. code-block:: js + + function somethingListener(fire, minValue) => { + const callback = value => { + if (value >= minValue) { + fire.async(value); + } + }; + RegisterSomeInternalCallback(callback); + return { + unregister() { + UnregisterInternalCallback(callback); + }, + convert(_fire, context) { + fire = _fire; + } + }; + } + + this.myapi = class extends ExtensionAPI { + primeListener(extension, event, fire, params, isInStartup) { + if (event == "onSomething") { + // Note that we return the object with unregister and convert here. + return somethingListener(fire, ...params); + } + // If an event other than onSomething was requested, we are not returning + // anything for it, thus it would not be persistable. + } + getAPI(context) { + return { + myapi: { + onSomething: new EventManager({ + context, + module: "myapi", + event: "onSomething", + register: (fire, minValue) => { + // Note that we return unregister here. + return somethingListener(fire, minValue).unregister; + } + }).api() + } + } + } + } diff --git a/toolkit/components/extensions/docs/functions.rst b/toolkit/components/extensions/docs/functions.rst new file mode 100644 index 0000000000..f1727aceed --- /dev/null +++ b/toolkit/components/extensions/docs/functions.rst @@ -0,0 +1,201 @@ +Implementing a function +======================= +Implementing an API function requires at least two different pieces: +a definition for the function in the schema, and Javascript code that +actually implements the function. + +Declaring a function in the API schema +-------------------------------------- +An API schema definition for a simple function looks like this: + +.. code-block:: json + + [ + { + "namespace": "myapi", + "functions": [ + { + "name": "add", + "type": "function", + "description": "Adds two numbers together.", + "async": true, + "parameters": [ + { + "name": "x", + "type": "number", + "description": "The first number to add." + }, + { + "name": "y", + "type": "number", + "description": "The second number to add." + } + ] + } + ] + } + ] + +The ``type`` and ``description`` properties were described above. +The ``name`` property is the name of the function as it appears in +the given namespace. That is, the fragment above creates a function +callable from an extension as ``browser.myapi.add()``. +The ``parameters`` property describes the parameters the function takes. +Parameters are specified as an array of Javascript types, where each +parameter is a constrained Javascript value as described +in the previous section. + +Each parameter may also contain additional properties ``optional`` +and ``default``. If ``optional`` is present it must be a boolean +(and parameters are not optional by default so this property is typically +only added when it has the value ``true``). +The ``default`` property is only meaningful for optional parameters, +it specifies the value that should be used for an optional parameter +if the function is called without that parameter. +An optional parameter without an explicit ``default`` property will +receive a default value of ``null``. +Although it is legal to create optional parameters at any position +(i.e., optional parameters can come before required parameters), doing so +leads to difficult to use functions and API designers are encouraged to +use object-valued parameters with optional named properties instead, +or if optional parameters must be used, to use them sparingly and put +them at the end of the parameter list. + +.. XXX should we describe allowAmbiguousArguments? + +The boolean-valued ``async`` property specifies whether a function +is asynchronous. +For asynchronous functions, +the WebExtensions framework takes care of automatically generating a +`Promise `_ and then resolving the Promise when the function +implementation completes (or rejecting the Promise if the implementation +throws an Error). +Since extensions can run in a child process, any API function that is +implemented (either partially or completely) in the parent process must +be asynchronous. + +When a function is declared in the API schema, a wrapper for the function +is automatically created and injected into appropriate extension Javascript +contexts. This wrapper automatically validates arguments passed to the +function against the formal parameters declared in the schema and immediately +throws an Error if invalid arguments are passed. +It also processes optional arguments and inserts default values as needed. +As a result, API implementations generally do not need to write much +boilerplate code to validate and interpret arguments. + +Implementing a function in the main process +------------------------------------------- +If an asynchronous function is not implemented in the child process, +the wrapper generated from the schema automatically marshalls the +function arguments, sends the request to the parent process, +and calls the implementation there. +When that function completes, the return value is sent back to the child process +and the Promise for the function call is resolved with that value. + +Based on this, an implementation of the function we wrote the schema +for above looks like this: + +.. code-block:: js + + this.myapi = class extends ExtensionAPI { + getAPI(context) { + return { + myapi: { + add(x, y) { return x+y; } + } + } + } + } + +The implementations of API functions are contained in a subclass of the +`ExtensionAPI `_ class. +Each subclass of ExtensionAPI must implement the ``getAPI()`` method +which returns an object with a structure that mirrors the structure of +functions and events that the API exposes. +The ``context`` object passed to ``getAPI()`` is an instance of +`BaseContext `_, +which contains a number of useful properties and methods. + +If an API function implementation returns a Promise, its result will +be sent back to the child process when the Promise is settled. +Any other return type will be sent directly back to the child process. +A function implementation may also raise an Error. But by default, +an Error thrown from inside an API implementation function is not +exposed to the extension code that called the function -- it is +converted into generic errors with the message "An unexpected error occurred". +To throw a specific error to extensions, use the ``ExtensionError`` class: + +.. code-block:: js + + this.myapi = class extends ExtensionAPI { + getAPI(context) { + return { + myapi: { + doSomething() { + if (cantDoSomething) { + throw new ExtensionError("Cannot call doSomething at this time"); + } + return something(); + } + } + } + } + } + +The purpose of this step is to avoid bugs in API implementations from +exposing details about the implementation to extensions. When an Error +that is not an instance of ExtensionError is thrown, the original error +is logged to the +`Browser Console `_, +which can be useful while developing a new API. + +Implementing a function in a child process +------------------------------------------ +Most functions are implemented in the main process, but there are +occasionally reasons to implement a function in a child process, such as: + +- The function has one or more parameters of a type that cannot be automatically + sent to the main process using the structured clone algorithm. + +- The function implementation interacts with some part of the browser + internals that is only accessible from a child process. + +- The function can be implemented substantially more efficiently in + a child process. + +To implement a function in a child process, simply include an ExtensionAPI +subclass that is loaded in the appropriate context +(e.g, ``addon_child``, ``content_child``, etc.) as described in +the section on :ref:`basics`. +Code inside an ExtensionAPI subclass in a child process may call the +implementation of a function in the parent process using a method from +the API context as follows: + +.. code-block:: js + + this.myapi = class extends ExtensionAPI { + getAPI(context) { + return { + myapi: { + async doSomething(arg) { + let result = await context.childManager.callParentAsyncFunction("anothernamespace.functionname", [arg]); + /* do something with result */ + return ...; + } + } + } + } + } + +As you might expect, ``callParentAsyncFunction()`` calls the given function +in the main process with the given arguments, and returns a Promise +that resolves with the result of the function. +This is the same mechanism that is used by the automatically generated +function wrappers for asynchronous functions that do not have a +provided implementation in a child process. + +It is possible to define the same function in both the main process +and a child process and have the implementation in the child process +call the function with the same name in the parent process. +This is a common pattern when the implementation of a particular function +requires some code in both the main process and child process. diff --git a/toolkit/components/extensions/docs/generate_webidl_from_jsonschema.rst b/toolkit/components/extensions/docs/generate_webidl_from_jsonschema.rst new file mode 100644 index 0000000000..f4514bfa25 --- /dev/null +++ b/toolkit/components/extensions/docs/generate_webidl_from_jsonschema.rst @@ -0,0 +1,94 @@ +Generating WebIDL definitions from WebExtensions API JSONSchema +=============================================================== + +In ``toolkit/components/extensions/webidl-api``, a python script named ``GenerateWebIDLBindings.py`` +helps to generation of the WebIDL definitions for the WebExtensions API namespaces based on the existing +JSONSchema data. + +.. figure:: generate_webidl_from_jsonschema_dataflow.drawio.svg + :alt: Diagram of the GenerateWebIDLBindings.py script data flow + +.. + This svg diagram has been created using https://app.diagrams.net, + the svg file also includes the source in the drawio format and so + it can be edited more easily by loading it back into app.diagrams.net + and then re-export from there (and include the updated drawio format + content into the exported svg file). + +Example: how to execute GenerateWebIDLBindings.py +------------------------------------------------- + +As an example, the following shell command generates (or regenerates if one exists) the webidl bindings +for the `runtime` API namespace: + +.. code-block:: bash + + $ export SCRIPT_DIR="toolkit/components/extensions/webidl-api" + $ mach python $SCRIPT_DIR/GenerateWebIDLBindings.py -- runtime + +this command will generates a `.webdil` file named `dom/webidl/ExtensionRuntime.webidl`. + +.. warning:: + This python script uses some python libraries part of mozilla-central ``mach`` command + and so it has to be executed using ``mach python`` and any command line options that has + to the passed to the ``GenerateWebIDLBindings.py`` script should be passed after the ``--`` + one that ends ``mach python`` own command line options. + +* If a webidl file with the same name already exist, the python script will ask confirmation and + offer to print a diff of the changes (or just continue without changing the existing webidl file + if the content is exactly the same): + +.. code-block:: console + + $ mach python $SCRIPT_DIR/GenerateWebIDLBindings.py -- runtime + + Generating webidl definition for 'runtime' => dom/webidl/ExtensionRuntime.webidl + Found existing dom/webidl/ExtensionRuntime.webidl. + + (Run again with --overwrite-existing to allow overwriting it automatically) + + Overwrite dom/webidl/ExtensionRuntime.webidl? (Yes/No/Diff) + D + --- ExtensionRuntime.webidl--existing + +++ ExtensionRuntime.webidl--updated + @@ -24,6 +24,9 @@ + [Exposed=(ServiceWorker), LegacyNoInterfaceObject] + interface ExtensionRuntime { + // API methods. + + + + [Throws, WebExtensionStub="Async"] + + any myNewMethod(boolean aBoolParam, optional Function callback); + + [Throws, WebExtensionStub="Async"] + any openOptionsPage(optional Function callback); + + + Overwrite dom/webidl/ExtensionRuntime.webidl? (Yes/No/Diff) + +* By convention each WebExtensions API WebIDL binding is expected to be paired with C++ files + named ``ExtensionMyNamespace.h`` and ``ExtensionMyNamespace.cpp`` and located in + ``toolkit/components/extensions/webidl-api``: + + * if no files with the expected names is found the python script will generate an initial + boilerplate files and will store them in the expected mozilla-central directory. + * The Firefox developers are responsible to fill this initial boilerplate as needed and + to apply the necessary changes (if any) when the webidl definitions are updated because + of changes to the WebExtensions APIs JSONSchema. + +``ExtensionWebIDL.conf`` config file +------------------------------------ + +TODO: + +* mention the role of the "webidl generation" script config file in handling + special cases (e.g. mapping types and method stubs) + +* notes on desktop-only APIs and API namespaces only partially available on Android + + +``WebExtensionStub`` WebIDL extended attribute +---------------------------------------------- + +TODO: + +* mention the special webidl extended attribute used in the WebIDL definitions diff --git a/toolkit/components/extensions/docs/generate_webidl_from_jsonschema_dataflow.drawio.svg b/toolkit/components/extensions/docs/generate_webidl_from_jsonschema_dataflow.drawio.svg new file mode 100644 index 0000000000..aaa5a4c3e0 --- /dev/null +++ b/toolkit/components/extensions/docs/generate_webidl_from_jsonschema_dataflow.drawio.svg @@ -0,0 +1,4 @@ + + + +
generate initial
boilerplate
generate initial...
Script config file
Script config file
ExtensionWebIDL.conf
ExtensionWebIDL.conf
toolkit/components/extensions/webidl-api/
GenerateWebIDLBindings.py
toolkit/components/extensions/webidl-api/...
generate / update
generate / update
ExtensionMyAPI.webidl
ExtensionMyAPI.webidl
dom/webidl
dom/webidl
Toolkit-level schema
files
Toolkit-level schema...
WebExtension JSONSChema files
WebExtension JSONSChema files
Desktop-level schema
files
Desktop-level schema...
Mobile-level schema
files
Mobile-level schema...
WebExtensions API
namespace name
WebExtensions API...
Script CLI options
Script CLI options
ExtensionAPI.webidl.in
ExtensionAPI.webidl.in
ExtensionAPI.[cpp|h].in
ExtensionAPI.[cpp|h].in
Jinja-based templates
Jinja-based templates
ExtensionMyAPI.h/cpp
ExtensionMyAPI.h/cpp
toolkit/components/extensions/webidl-api
toolkit/components/extensi...
  • mapping JSONSchema to WebIDL types
  • mapping API method to webidl WebExtensionStub extended attribute
  • mapping schema group (toolkit, desktop, mobile) to mozilla-central dir paths
mapping JSONSchema to WebIDL types...
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/toolkit/components/extensions/docs/incognito.rst b/toolkit/components/extensions/docs/incognito.rst new file mode 100644 index 0000000000..7df71e77c4 --- /dev/null +++ b/toolkit/components/extensions/docs/incognito.rst @@ -0,0 +1,78 @@ +.. _incognito: + +Incognito Implementation +======================== + +This page provides a high level overview of how incognito works in +Firefox, primarily to help in understanding how to test the feature. + +The Implementation +------------------ + +The incognito value in manifest.json supports ``spanning`` and ``not_allowed``. +The other value, ``split``, may be supported in the future. The default +value is ``spanning``, however, by default access to private windows is +not allowed. The user must turn on support, per extension, in ``about:addons``. + +Internally this is handled as a hidden extension permission called +``internal:privateBrowsingAllowed``. This permission is reset when the +extension is disabled or uninstalled. The permission is accessible in +several ways: + +- extension.privateBrowsingAllowed +- context.privateBrowsingAllowed (see BaseContext) +- WebExtensionPolicy.privateBrowsingAllowed +- WebExtensionPolicy.canAccessWindow(DOMWindow) + +Testing +------- + +The goal of testing is to ensure that data from a private browsing session +is not accessible to an extension without permission. + +In Firefox 67, the feature will initially be disabled, however the +intention is to enable the feature on in 67. The pref controlling this +is ``extensions.allowPrivateBrowsingByDefault``. When this pref is +``true``, all extensions have access to private browsing and the manifest +value ``not_allowed`` will produce an error. To enable incognito.not_allowed +for tests you must flip the pref to false. + +Testing EventManager events +--------------------------- + +This is typically most easily handled by running a test with an extension +that has permission, using ``incognitoOverride: spanning`` in the call to +ExtensionTestUtils.loadExtension. You can then use a second extension +without permission to try and catch any events that would typically be passed. + +If the events can happen without calls produced by an extension, you can +also use BrowserTestUtils to open a private window, and use a non-permissioned +extension to run tests against it. + +There are two utility functions in head.js, getIncognitoWindow and +startIncognitoMonitorExtension, which are useful for some basic testing. + +Example: `browser_ext_windows_events.js `_ + +Testing API Calls +----------------- + +This is easily done using an extension without permission. If you need +an ID of a window or tab, use getIncognitoWindow. In most cases, the +API call should throw an exception when the window is not accessible. +There are some cases where API calls explicitly do not throw. + +Example: `browser_ext_windows_incognito.js `_ + +Privateness of window vs. tab +----------------------------- + +Android does not currently support private windows. When a tab is available, +the test should prefer tab over window. + +- PrivateBrowsingUtils.isBrowserPrivate(tab.linkedBrowser) +- PrivateBrowsingUtils.isContentWindowPrivate(window) + +When WebExtensionPolicy is handy to use, you can directly check window access: + +- policy.canAccessWindow(window) diff --git a/toolkit/components/extensions/docs/index.rst b/toolkit/components/extensions/docs/index.rst new file mode 100644 index 0000000000..63a7c3685c --- /dev/null +++ b/toolkit/components/extensions/docs/index.rst @@ -0,0 +1,33 @@ +WebExtensions API Development +============================= + +This documentation covers the implementation of WebExtensions inside Firefox. +Documentation about existing WebExtension APIs and how to use them +to develop WebExtensions is available +`on MDN `_. + +To use this documentation, you should already be familiar with +WebExtensions, including +`the anatomy of a WebExtension `_ +and `permissions `_. +You should also be familiar with concepts from +`Firefox development `_ +including `e10s `_ +in particular. + +.. toctree:: + :caption: WebExtension API Developers Guide + :maxdepth: 2 + + background + basics + schema + functions + events + manifest + lifecycle + incognito + webidl_bindings + webext-storage + other + reference diff --git a/toolkit/components/extensions/docs/lifecycle.rst b/toolkit/components/extensions/docs/lifecycle.rst new file mode 100644 index 0000000000..8f08b34b68 --- /dev/null +++ b/toolkit/components/extensions/docs/lifecycle.rst @@ -0,0 +1,60 @@ +.. _lifecycle: + +Managing the Extension Lifecycle +================================ +The techniques described in previous pages allow a WebExtension API to +be loaded and instantiated only when an extension that uses the API is +activated. +But there are a few other events in the extension lifecycle that an API +may need to respond to. + +Extension Shutdown +------------------ +APIs that allocate any resources (e.g., adding elements to the browser's +user interface, setting up internal event listeners, etc.) must free +these resources when the extension for which they are allocated is +shut down. An API does this by using the ``callOnClose()`` +method on an `Extension `_ object. + +Extension Uninstall and Update +------------------------------ +In addition to resources allocated within an individual browser session, +some APIs make durable changes such as setting preferences or storing +data in the user's profile. +These changes are typically not reverted when an extension is shut down, +but when the extension is completely uninstalled (or stops using the API). +To handle this, extensions can be notified when an extension is uninstalled +or updated. Extension updates are a subtle case -- consider an API that +makes some durable change based on the presence of a manifest property. +If an extension uses the manifest key in one version and then is updated +to a new version that no longer uses the manifest key, +the ``onManifestEntry()`` method for the API is no longer called, +but an API can examine the new manifest after an update to detect that +the key has been removed. + +Handling lifecycle events +------------------------- + +To be notified of update and uninstall events, an extension lists these +events in the API manifest: + +.. code-block:: js + + "myapi": { + "schema": "...", + "url": "...", + "events": ["update", "uninstall"] + } + +If these properties are present, the ``onUpdate()`` and ``onUninstall()`` +methods will be called for the relevant ``ExtensionAPI`` instances when +an extension that uses the API is updated or uninstalled. + +Note that these events can be triggered on extensions that are inactive. +For that reason, these events can only be handled by extension APIs that +are built into the browser. Or, in other words, these events cannot be +handled by APIs that are implemented in WebExtension experiments. If the +implementation of an API relies on these events for correctness, the API +must be built into the browser and not delivered via an experiment. + +.. Should we even document onStartup()? I think no... diff --git a/toolkit/components/extensions/docs/manifest.rst b/toolkit/components/extensions/docs/manifest.rst new file mode 100644 index 0000000000..194dc43a8d --- /dev/null +++ b/toolkit/components/extensions/docs/manifest.rst @@ -0,0 +1,68 @@ +Implementing a manifest property +================================ +Like functions and events, implementing a new manifest key requires +writing a definition in the schema and extending the API's instance +of ``ExtensionAPI``. + +The contents of a WebExtension's ``manifest.json`` are validated using +a type called ``WebExtensionManifest`` defined in the namespace +``manifest``. +The first step when adding a new property is to extend the schema so +that manifests containing the new property pass validation. +This is done with the ``"$extend"`` property as follows: + +.. code-block:: js + + [ + "namespace": "manifest", + "types": [ + { + "$extend": "WebExtensionManifest", + "properties": { + "my_api_property": { + "type": "string", + "optional": true, + ... + } + } + } + ] + ] + +The next step is to inform the WebExtensions framework that this API +should be instantiated and notified when extensions that use the new +manifest key are loaded. +For built-in APIs, this is done with the ``manifest`` property +in the API manifest (e.g., ``ext-toolkit.json``). +Note that this property is an array so an extension can implement +multiple properties: + +.. code-block:: js + + "myapi": { + "schema": "...", + "url": "...", + "manifest": ["my_api_property"] + } + +The final step is to write code to handle the new manifest entry. +The WebExtensions framework processes an extension's manifest when the +extension starts up, this happens for existing extensions when a new +browser session starts up and it can happen in the middle of a session +when an extension is first installed or enabled, or when the extension +is updated. +The JSON fragment above causes the WebExtensions framework to load the +API implementation when it encounters a specific manifest key while +starting an extension, and then call its ``onManifestEntry()`` method +with the name of the property as an argument. +The value of the property is not passed, but the full manifest is +available through ``this.extension.manifest``: + +.. code-block:: js + + this.myapi = class extends ExtensionAPI { + onManifestEntry(name) { + let value = this.extension.manifest.my_api_property; + /* do something with value... */ + } + } diff --git a/toolkit/components/extensions/docs/other.rst b/toolkit/components/extensions/docs/other.rst new file mode 100644 index 0000000000..85a9b6db41 --- /dev/null +++ b/toolkit/components/extensions/docs/other.rst @@ -0,0 +1,140 @@ +Utilities for implementing APIs +=============================== + +This page covers some utility classes that are useful for +implementing WebExtension APIs: + +WindowManager +------------- +This class manages the mapping between the opaque window identifiers used +in the `browser.windows `__ API. +See the reference docs `here `__. + +TabManager +---------- +This class manages the mapping between the opaque tab identifiers used +in the `browser.tabs `__ API. +See the reference docs `here `__. + +ExtensionSettingsStore +---------------------- +ExtensionSettingsStore (ESS) is used for storing changes to settings that are +requested by extensions, and for finding out what the current value +of a setting should be, based on the precedence chain or a specific selection +made (typically) by the user. + +When multiple extensions request to make a change to a particular +setting, the most recently installed extension will be given +precedence. + +It is also possible to select a specific extension (or no extension, which +infers user-set) to control a setting. This will typically only happen via +ExtensionPreferencesManager described below. When this happens, precedence +control is not used until either a new extension is installed, or the controlling +extension is disabled or uninstalled. If user-set is specifically chosen, +precedence order will only be returned to by installing a new extension that +takes control of the setting. + +ESS will manage what has control over a setting through any +extension state changes (ie. install, uninstall, enable, disable). + +Notifications: +^^^^^^^^^^^^^^ + +"extension-setting-changed": +**************************** + + When a setting changes an event is emitted via the apiManager. It contains + the following: + + * *action*: one of select, remove, enable, disable + + * *id*: the id of the extension for which the setting has changed, may be null + if the setting has returned to default or user set. + + * *type*: The type of setting altered. This is defined by the module using ESS. + If the setting is controlled through the ExtensionPreferencesManager below, + the value will be "prefs". + + * *key*: The name of the setting altered. + + * *item*: The new value, if any that has taken control of the setting. + + +ExtensionPreferencesManager +--------------------------- +ExtensionPreferencesManager (EPM) is used to manage what extensions may control a +setting that results in changing a preference. EPM adds additional logic on top +of ESS to help manage the preference values based on what is in control of a +setting. + +Defining a setting in an API +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +A preference setting is defined in an API module by calling EPM.addSetting. addSetting +allows the API to use callbacks that can handle setting preferences as needed. Since +the setting is defined at runtime, the API module must be loaded as necessary by EPM +to properly manage settings. + +In the api module definition (e.g. ext-toolkit.json), the api must use `"settings": true` +so the management code can discover which API modules to load in order to manage a +setting. See browserSettings[1] as an example. + +Settings that are exposed to the user in about:preferences also require special handling. +We typically show that an extension is in control of the preference, and prevent changes +to the setting. Some settings may allow the user to choose which extension (or none) has +control of the setting. + +Preferences behavior +^^^^^^^^^^^^^^^^^^^^ + +To actually set a setting, the module must call EPM.setSetting. This is typically done +via an extension API, such as browserSettings.settingName.set({ ...value data... }), though +it may be done at other times, such as during extension startup or install in a modules +onManifest handler. + +Preferences are not always changed when an extension uses an API that results in a call +to EPM.setSetting. When setSetting is called, the values are stored by ESS (above), and if +the extension currently has control, or the setting is controllable by the extension, then +the preferences would be updated. + +The preferences would also potentially be updated when installing, enabling, disabling or +uninstalling an extension, or by a user action in about:preferences (or other UI that +allows controlling the preferences). If all extensions that use a preference setting are +disabled or uninstalled, the prior user-set or default values would be returned to. + +An extension may watch for changes using the onChange api (e.g. browserSettings.settingName.onChange). + +[1] https://searchfox.org/mozilla-central/rev/04d8e7629354bab9e6a285183e763410860c5006/toolkit/components/extensions/ext-toolkit.json#19 + +Notifications: +^^^^^^^^^^^^^^ + +"extension-setting-changed:*name*": +*********************************** + + When a setting controlled by EPM changes an event is emitted via the apiManager. It contains + no other data. This is used primarily to implement the onChange API. + +ESS vs. EPM +----------- +An API may use ESS when it needs to allow an extension to store a setting value that +affects how Firefox works, but does not result in setting a preference. An example +is allowing an extension to change the newTab value in the newTab service. + +An API should use EPM when it needs to allow an extension to change a preference. + +Using ESS/EPM with experimental APIs +------------------------------------ + +Properly managing settings values depends on the ability to load any modules that +define a setting. Since experimental APIs are defined inside the extension, there +are situations where settings defined in experimental APIs may not be correctly +managed. The could result in a preference remaining set by the extension after +the extension is disabled or installed, especially when that state is updated during +safe mode. + +Extensions making use of settings in an experimental API should practice caution, +potentially unsetting the values when the extension is shutdown. Values used for +the setting could be stored in the extensions locale storage, and restored into +EPM when the extension is started again. diff --git a/toolkit/components/extensions/docs/reference.rst b/toolkit/components/extensions/docs/reference.rst new file mode 100644 index 0000000000..f88c0b872e --- /dev/null +++ b/toolkit/components/extensions/docs/reference.rst @@ -0,0 +1,35 @@ +WebExtensions Javascript Component Reference +============================================ +This page contains reference documentation for the individual classes +used to implement WebExtensions APIs. This documentation is generated +from jsdoc comments in the source code. + +ExtensionAPI class +------------------ +.. js:autoclass:: ExtensionAPI + :members: + +Extension class +--------------- +.. js:autoclass:: Extension + :members: + +EventManager class +------------------ +.. js:autoclass:: EventManager + :members: + +BaseContext class +----------------- +.. js:autoclass:: BaseContext + :members: + +WindowManager class +------------------- +.. js:autoclass:: WindowManagerBase + :members: + +TabManager class +---------------- +.. js:autoclass:: TabManagerBase + :members: diff --git a/toolkit/components/extensions/docs/schema.rst b/toolkit/components/extensions/docs/schema.rst new file mode 100644 index 0000000000..522328b4ec --- /dev/null +++ b/toolkit/components/extensions/docs/schema.rst @@ -0,0 +1,145 @@ +API Schemas +=========== +Anything that a WebExtension API exposes to extensions via Javascript +is described by the API's schema. The format of API schemas uses some +of the same syntax as `JSON Schema `_. +JSON Schema provides a way to specify constraints on JSON documents and +the same method is used by WebExtensions to specify constraints on, +for example, parameters passed to an API function. But the syntax for +describing functions, namespaces, etc. is all ad hoc. This section +describes that syntax. + +An individual API schema consists of structured descriptions of +items in one or more *namespaces* using a structure like this: + +.. code-block:: js + + [ + { + "namespace": "namespace1", + // declarations for namespace 1... + }, + { + "namespace": "namespace2", + // declarations for namespace 2... + }, + // other namespaces... + ] + +Most of the namespaces correspond to objects available to extensions +Javascript code under the ``browser`` global. For example, entries in the +namespace ``example`` are accessible to extension Javascript code as +properties on ``browser.example``. +The namespace ``"manifest"`` is handled specially, it describes the +structure of WebExtension manifests (i.e., ``manifest.json`` files). +Manifest schemas are explained in detail below. + +Declarations within a namespace look like: + +.. code-block:: js + + { + "namespace": "namespace1", + "types": [ + { /* type definition */ }, + ... + ], + "properties": { + "NAME": { /* property definition */ }, + ... + }, + "functions": [ + { /* function definition */ }, + ... + ], + "events": [ + { /* event definition */ }, + ... + ] + } + +The four types of objects that can be defined inside a namespace are: + +- **types**: A type is a reusable schema fragment. A common use of types + is to define in one place an object with a particular set of typed fields + that is used in multiple places in an API. + +- **properties**: A property is a fixed Javascript value available to + extensions via Javascript. Note that the format for defining + properties in a schema is different from the format for types, functions, + and events. The next subsection describes creating properties in detail. + +- **functions** and **events**: + These entries create functions and events respectively, which are + usable from Javascript by extensions. Details on how to implement + them are later in this section. + +Implementing a fixed Javascript property +---------------------------------------- +A static property is made available to extensions via Javascript +entirely from the schema, using a fragment like this one: + +.. code-block:: js + + [ + "namespace": "myapi", + "properties": { + "SOME_PROPERTY": { + "value": 24, + "description": "Description of my property here." + } + } + ] + +If a WebExtension API with this fragment in its schema is loaded for +a particular extension context, that extension will be able to access +``browser.myapi.SOME_PROPERTY`` and read the fixed value 24. +The contents of ``value`` can be any JSON serializable object. + +Schema Items +------------ +Most definitions of individual items in a schema have a common format: + +.. code-block:: js + + { + "type": "SOME TYPE", + /* type-specific parameters... */ + } + +Type-specific parameters will be described in subsequent sections, +but there are some optional properties that can appear in many +different types of items in an API schema: + +- ``description``: This string-valued property serves as documentation + for anybody reading or editing the schema. + +- ``permissions``: This property is an array of strings. + If present, the item in which this property appears is only made + available to extensions that have all the permissions listed in the array. + +- ``unsupported``: This property must be a boolean. + If it is true, the item in which it appears is ignored. + By using this property, a schema can define how a particular API + is intended to work, before it is implemented. + +- ``deprecated``: This property must be a boolean. If it is true, + any uses of the item in which it appears will cause a warning to + be logged to the browser console, to indicate to extension authors + that they are using a feature that is deprecated or otherwise + not fully supported. + + +Describing constrained values +----------------------------- +There are many places where API schemas specify constraints on the type +and possibly contents of some JSON value (e.g., the manifest property +``name`` must be a string) or Javascript value (e.g., the first argument +to ``browser.tabs.get()`` must be a non-negative integer). +These items are defined using `JSON Schema `_. +Specifically, these items are specified by using one of the following +values for the ``type`` property: ``boolean``, ``integer``, ``number``, +``string``, ``array``, ``object``, or ``any``. +Refer to the documentation and examples at the +`JSON Schema site `_ for details on how these +items are defined in a schema. diff --git a/toolkit/components/extensions/docs/webext-storage.rst b/toolkit/components/extensions/docs/webext-storage.rst new file mode 100644 index 0000000000..9b5f2428d6 --- /dev/null +++ b/toolkit/components/extensions/docs/webext-storage.rst @@ -0,0 +1,227 @@ +======================== +How webext storage works +======================== + +This document describes the implementation of the the `storage.sync` part of the +`WebExtensions Storage APIs +`_. +The implementation lives in the `toolkit/components/extensions/storage folder `_ + +Ideally you would already know about Rust and XPCOM - `see this doc for more details <../../../../writing-rust-code/index.html>`_ + +At a very high-level, the system looks like: + +.. mermaid:: + + graph LR + A[Extensions API] + A --> B[Storage JS API] + B --> C{magic} + C --> D[app-services component] + +Where "magic" is actually the most interesting part and the primary focus of this document. + + Note: The general mechanism described below is also used for other Rust components from the + app-services team - for example, "dogear" uses a similar mechanism, and the sync engines + too (but with even more complexity) to manage the threads. Unfortunately, at time of writing, + no code is shared and it's not clear how we would, but this might change as more Rust lands. + +The app-services component `lives on github `_. +There are docs that describe `how to update/vendor this (and all) external rust code <../../../../build/buildsystem/rust.html>`_ you might be interested in. + +To set the scene, let's look at the parts exposed to WebExtensions first; there are lots of +moving part there too. + +WebExtension API +################ + +The WebExtension API is owned by the addons team. The implementation of this API is quite complex +as it involves multiple processes, but for the sake of this document, we can consider the entry-point +into the WebExtension Storage API as being `parent/ext-storage.js `_ + +This entry-point ends up using the implementation in the +`ExtensionStorageSync JS class `_. +This class/module has complexity for things like migration from the earlier Kinto-based backend, +but importantly, code to adapt a callback API into a promise based one. + +Overview of the API +################### + +At a high level, this API is quite simple - there are methods to "get/set/remove" extension +storage data. Note that the "external" API exposed to the addon has subtly changed the parameters +for this "internal" API, so there's an extension ID parameter and the JSON data has already been +converted to a string. +The semantics of the API are beyond this doc but are +`documented on MDN `_. + +As you will see in those docs, the API is promise-based, but the rust implementation is fully +synchronous and Rust knows nothing about Javascript promises - so this system converts +the callback-based API to a promise-based one. + +xpcom as the interface to Rust +############################## + +xpcom is old Mozilla technology that uses C++ "vtables" to implement "interfaces", which are +described in IDL files. While this traditionally was used to interface +C++ and Javascript, we are leveraging existing support for Rust. The interface we are +exposing is described in `mozIExtensionStorageArea.idl `_ + +The main interface of interest in this IDL file is `mozIExtensionStorageArea`. +This interface defines the functionality - and is the first layer in the sync to async model. +For example, this interface defines the following method: + +.. code-block:: rust + + interface mozIExtensionStorageArea : nsISupports { + ... + // Sets one or more key-value pairs specified in `json` for the + // `extensionId`... + void set(in AUTF8String extensionId, + in AUTF8String json, + in mozIExtensionStorageCallback callback); + +As you will notice, the 3rd arg is another interface, `mozIExtensionStorageCallback`, also +defined in that IDL file. This is a small, generic interface defined as: + +.. code-block:: cpp + + interface mozIExtensionStorageCallback : nsISupports { + // Called when the operation completes. Operations that return a result, + // like `get`, will pass a `UTF8String` variant. Those that don't return + // anything, like `set` or `remove`, will pass a `null` variant. + void handleSuccess(in nsIVariant result); + + // Called when the operation fails. + void handleError(in nsresult code, in AUTF8String message); + }; + +Note that this delivers all results and errors, so must be capable of handling +every result type, which for some APIs may be problematic - but we are very lucky with this API +that this simple XPCOM callback interface is capable of reasonably representing the return types +from every function in the `mozIExtensionStorageArea` interface. + +(There's another interface, `mozIExtensionStorageListener` which is typically +also implemented by the actual callback to notify the extension about changes, +but that's beyond the scope of this doc.) + +*Note the thread model here is async* - the `set` call will return immediately, and later, on +the main thread, we will call the callback param with the result of the operation. + +So under the hood, what happens is something like: + +.. mermaid:: + + sequenceDiagram + Extension->>ExtensionStorageSync: call `set` and give me a promise + ExtensionStorageSync->>xpcom: call `set`, supplying new data and a callback + ExtensionStorageSync-->>Extension: your promise + xpcom->>xpcom: thread magic in the "bridge" + xpcom-->>ExtensionStorageSync: callback! + ExtensionStorageSync-->>Extension: promise resolved + +So onto the thread magic in the bridge! + +webext_storage_bridge +##################### + +The `webext_storage_bridge `_ +is a Rust crate which, as implied by the name, is a "bridge" between this Javascript/XPCOM world to +the actual `webext-storage `_ crate. + +lib.rs +------ + +Is the entry-point - it defines the xpcom "factory function" - +an `extern "C"` function which is called by xpcom to create the Rust object +implementing `mozIExtensionStorageArea` using existing gecko support. + +area.rs +------- + +This module defines the interface itself. For example, inside that file you will find: + +.. code-block:: rust + + impl StorageSyncArea { + ... + + xpcom_method!( + set => Set( + ext_id: *const ::nsstring::nsACString, + json: *const ::nsstring::nsACString, + callback: *const mozIExtensionStorageCallback + ) + ); + /// Sets one or more key-value pairs. + fn set( + &self, + ext_id: &nsACString, + json: &nsACString, + callback: &mozIExtensionStorageCallback, + ) -> Result<()> { + self.dispatch( + Punt::Set { + ext_id: str::from_utf8(&*ext_id)?.into(), + value: serde_json::from_str(str::from_utf8(&*json)?)?, + }, + callback, + )?; + Ok(()) + } + + +Of interest here: + +* `xpcom_method` is a Rust macro, and part of the existing xpcom integration which already exists + in gecko. It declares the xpcom vtable method described in the IDL. + +* The `set` function is the implementation - it does string conversions and the JSON parsing + on the main thread, then does the work via the supplied callback param, `self.dispatch` and a `Punt`. + +* The `dispatch` method dispatches to another thread, leveraging existing in-tree `moz_task `_ support, shifting the `Punt` to another thread and making the callback when done. + +Punt +---- + +`Punt` is a whimsical name somewhat related to a "bridge" - it carries things across and back. + +It is a fairly simple enum in `punt.rs `_. +It's really just a restatement of the API we expose suitable for moving across threads. In short, the `Punt` is created on the main thread, +then sent to the background thread where the actual operation runs via a `PuntTask` and returns a `PuntResult`. + +There's a few dances that go on, but the end result is that `inner_run() `_ +gets executed on the background thread - so for `Set`: + +.. code-block:: rust + + Punt::Set { ext_id, value } => { + PuntResult::with_change(&ext_id, self.store()?.get()?.set(&ext_id, value)?)? + } + +Here, `self.store()` is a wrapper around the actual Rust implementation from app-services with +various initialization and mutex dances involved - see `store.rs`. +ie, this function is calling our Rust implementation and stashing the result in a `PuntResult` + +The `PuntResult` is private to that file but is a simple struct that encapsulates both +the actual result of the function (also a set of changes to send to observers, but that's +beyond this doc). + +Ultimately, the `PuntResult` ends up back on the main thread once the call is complete +and arranges to callback the JS implementation, which in turn resolves the promise created in `ExtensionStorageSync.jsm` + +End result: +----------- + +.. mermaid:: + + sequenceDiagram + Extension->>ExtensionStorageSync: call `set` and give me a promise + ExtensionStorageSync->>xpcom - bridge main thread: call `set`, supplying new data and a callback + ExtensionStorageSync-->>Extension: your promise + xpcom - bridge main thread->>moz_task worker thread: Punt this + moz_task worker thread->>webext-storage: write this data to the database + webext-storage->>webext-storage: done: result/error and observers + webext-storage-->>moz_task worker thread: ... + moz_task worker thread-->>xpcom - bridge main thread: PuntResult + xpcom - bridge main thread-->>ExtensionStorageSync: callback! + ExtensionStorageSync-->>Extension: promise resolved diff --git a/toolkit/components/extensions/docs/webidl_bindings.rst b/toolkit/components/extensions/docs/webidl_bindings.rst new file mode 100644 index 0000000000..be8c63d0a7 --- /dev/null +++ b/toolkit/components/extensions/docs/webidl_bindings.rst @@ -0,0 +1,246 @@ +WebIDL WebExtensions API Bindings +================================= + +While on ``manifest_version: 2`` all the extension globals (extension pages and content scripts) +that lives on the main thread and the WebExtensions API bindings can be injected into the extension +global from the JS privileged code part of the WebExtensions internals (`See Schemas.inject defined in +Schemas.jsm `_), +in ``manifest_version: 3`` the extension will be able to declare a background service worker +instead of a background page, and the existing WebExtensions API bindings can't be injected into this +new extension global, because it lives off of the main thread. + +To expose WebExtensions API bindings to the WebExtensions ``background.service_worker`` global +we are in the process of generating new WebIDL bindings for the WebExtensions API. + +.. warning:: + + For more general in depth details about WebIDL in Gecko: + + - :doc:`/dom/bindings/webidl/index` + - :doc:`/dom/webIdlBindings/index` + +Review process on changes to webidl definitions +----------------------------------------------- + +.. note:: + + When new webidl definitions are being introduced for a WebExtensions API, or + existing ones need to be updated to stay in sync with changes applied to the + JSONSchema definitions of the same WebExtensions API, the resulting patch + will include a **new or changed WebIDL located at dom/webidl** and that part of the + patch **will require a mandatory review and sign-off from a peer part of the** + webidl_ **phabricator review group**. + +This section includes a brief description about the special setup of the +webidl files related to WebExtensions and other notes useful to the +WebIDL peers that will be reviewing and signing off these webidl files. + +.. _webidl: https://phabricator.services.mozilla.com/tag/webidl/ + +How/Where are these webidl interfaces restricted to the extensions background service workers? +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +All the webidl interfaces related to the extensions API are only visible in +specific extension globals: the WebExtensions background service worker +(a service worker declared in the extension ``manifest.json`` file, through +the ``background.service_worker`` manifest field). + +All webidl interfaces related to the WebExtensions API interfaces are exposed +through the ``ExtensionBrowser`` interface, which gets exposed into the +``ServiceWorkerGlobalScope`` through the ``ExtensionGlobalsMixin`` interface and +restricted to the WebExtensions background service worker through the +``mozilla::extensions::ExtensionAPIAllowed`` helper function. + +See ``ExtensionBrowser`` and ``ExtensionGlobalsMixin`` interfaces defined from +ExtensionBrowser.webidl_ and ``mozilla::extensions::ExtensionAPIAllowed`` defined in +ExtensionBrowser.cpp_. + +.. _ExtensionBrowser.webidl: https://searchfox.org/mozilla-central/source/dom/webidl/ExtensionBrowser.webidl +.. _ExtensionBrowser.cpp: https://searchfox.org/mozilla-central/source/toolkit/components/extensions/webidl-api/ExtensionBrowser.cpp + +Why do all the webidl interfaces for WebExtensions API use LegacyNoInterfaceObject? +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The existing WebExtensions API bindings are not exposing any constructor in the +globals where they are available (e.g. the webidl bindings for the ``browser.alarms`` +API namespace is defined by the ``ExtensionAlarms`` webidl interface, but there +shouldn't be any ``ExtensionAlarms`` constructor available as a global to extension +code running in the background service worker). + +A previous attempt to create W3C specs for the WebExtensions APIs described in WebIDL +syntaxes (https://browserext.github.io/browserext) was also using the same +``NoInterfaceObject`` WebIDL attribute on the definitions of the API namespace +with the same motivations (eg. see ``BrowserExtBrowserRuntime`` as defined here: +https://browserext.github.io/browserext/#webidl-definition-4). + +Bug 1713877_ is tracking a followup to determine a long term replacement for the +``LegacyNoInterfaceObject`` attribute currently being used. + +.. _1713877: https://bugzilla.mozilla.org/1713877 + +Background Service Workers API Request Handling +----------------------------------------------- + +.. figure:: webidl_bindings_backgroundWorker_apiRequestHandling.drawio.svg + :alt: High Level Diagram of the Background Service Worker API Request Handling + +.. + This svg diagram has been created using https://app.diagrams.net, + the svg file also includes the source in the drawio format and so + it can be edited more easily by loading it back into app.diagrams.net + and then re-export from there (and include the updated drawio format + content into the exported svg file). + +Generating WebIDL definitions from WebExtensions API JSONSchema +--------------------------------------------------------------- + +WebIDL definitions for the extension APIs are being generated based on the WebExtensions API JSONSchema +data (the same metadata used to generate the "privilged JS"-based API bindings). + +Most of the API methods in generated WebIDL are meant to be implemented using stub methods shared +between all WebExtensions API classes, a ``WebExtensionStub`` webidl extended attribute specify +which shared stub method should be used when the related API method is called. + +For more in depth details about how to generate or update webidl definition for an Extension API +given its API namespace: + +.. toctree:: + :maxdepth: 2 + + generate_webidl_from_jsonschema + +Wiring up new WebExtensions WebIDL files into mozilla-central +------------------------------------------------------------- + +After a new WebIDL definition has been generated, there are a few more steps to ensure that +the new WebIDL binding is wired up into mozilla-central build system and to be able to +complete successfully a full Gecko build that include the new bindings. + +For more in depth details about these next steps: + +.. toctree:: + :maxdepth: 2 + + wiring_up_new_webidl_bindings + +Testing WebExtensions WebIDL bindings +------------------------------------- + +Once the WebIDL definition for an WebExtensions API namespace has been +implemented and wired up, the following testing strategies are available to +cover them as part of the WebExtensions testing suites: + +``toolkit/components/extensions/test/xpcshell/webidl-api`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The xpcshell tests added to this group of xpcshell tests are meant to provide testing coverage +related to lower level components and behaviors (e.g. when making changes to the shared C++ +helpers defined in ``toolkit/components/extensions/webidl-api``, or adding new ones). + +These tests will often mock part of the internals and use a ``browser.mockExtensionAPI`` +API namespace which is only available in tests and not mapped to any actual API implementation +(instead it is being mocked in the test cases to recreate the scenario that the test case is meant +to cover). + +And so **they are not meant to provide any guarantees in terms of consistency of the behavior +of the two different bindings implementations** (the new WebIDL bindings vs. the current implemented +bindings), instead the other test suites listed in the sections below should be used for that purpose. + +All tests in this directory are skipped in builds where the WebExtensions WebIDL API bindings +are being disabled at build time (e.g. beta and release builds, where otherwise +the test would permafail while riding the train once got on those builds). + + +``toolkit/components/extensions/test/xpcshell/xpcshell-serviceworker.ini`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When a new or existing xpcshell tests added to this xpcshell-test manifest, all test extensions +will be generated with a background service worker instead of a background page. + +.. warning:: + **Unless the background page or scripts are declared as part of the test extension manifest**, + the test file added to this manifest should be explicitly reviewed to make sure all tests + are going to provide the expected test coverage in all modes. + +.. note:: + In a background script that runs in both a background page and a background + service worker it may be necessary to run different code for part of the + test, ``self !== self.window`` is a simple check that can be used to detect if + the script is being executed as a background service worker. + +Test tasks that should be skipped when running in "background service worker mode", but temporarily +until a followup fixes the underlying issue can use the ``ExtensionTestUtils.isInBackgroundServiceWorkerTests()`` in the optional +``add_task``'s ``skip_if`` parameter: + +.. code-block:: js + + add_task( + { + // TODO(Bug TBF): remove this once ... + skip_if: () => ExtensionTestUtils.isInBackgroundServiceWorkerTests(), + }, + async function test_someapi_under_scenario() { + ... + } + ); + +On the contrary if the test tasks is covering a scenario that is specific to a background page, +and it would need to be permanently skipped while the background script is running as a service worker, +it may be more appropriate to split out those tests in a separate test file not included in this +manifest. + +.. warning:: + Make sure that all tests running in multiple modes (in-process, + remote, and "background service worker mode") do not assume that the WebIDL + bindings and Background Service Worker are enabled and to skip them when appropriate, + otherwise the test will become a permafailure once it gets to a channel + where the "Extensions WebIDL API bindings" are disabled by default at build + time (currently on **beta** and **release**). + +While running the test files locally they will be executed once for each manifest file +where they are included, to restrict the run to just the "background service +workers mode" specify the ``sw-webextensions`` tag: + +.. code-block:: bash + + mach xpcshell-test --tag sw-webextensions path/to/test/file.js + +``toolkit/components/extensions/test/mochitest/mochitest-serviceworker.ini`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Same as the xpcshell-serviceworker.ini manifest but for the mochitest-plain tests. + +.. warning:: + The same warnings described in the xpcshell-serviceworker.ini subsection do + also apply to this manifest file. + +Test tasks that should be skipped when not running in "background service worker +mode" can be split into separate test file or skipped inside the ``add_task`` +body, but mochitests' ``add_task`` does not support a ``skip_if`` option and so +that needs to be done manually (and it may be good to also log a message to make +it visible when a test is skipped): + +.. code-block:: js + + add_task(async function test_someapi_in_background_service_worker() { + if (!ExtensionTestUtils.isInBackgroundServiceWorkerTests()) { + is( + ExtensionTestUtils.getBackgroundServiceWorkerEnabled(), + false, + "This test should only be skipped with background service worker disabled" + ) + info("Test intentionally skipped on 'extensions.backgroundServiceWorker.enabled=false'"); + return; + } + + + ... + }); + +While executing the test files locally they will run once for each manifest file +where they are included, to restrict the run to just the "background service +workers mode" specify the ``sw-webextensions`` tag: + +.. code-block:: bash + + mach mochitest --tag sw-webextensions path/to/test/file.js diff --git a/toolkit/components/extensions/docs/webidl_bindings_backgroundWorker_apiRequestHandling.drawio.svg b/toolkit/components/extensions/docs/webidl_bindings_backgroundWorker_apiRequestHandling.drawio.svg new file mode 100644 index 0000000000..ff1fc003ff --- /dev/null +++ b/toolkit/components/extensions/docs/webidl_bindings_backgroundWorker_apiRequestHandling.drawio.svg @@ -0,0 +1,4 @@ + + + +
IPC
IPC
Extension API namespace
(WebIDL - C++)
Extension API namesp...
mozIExtensionAPI
RequestHandler
(XPCOM - JS)
mozIExtensionAPI...
mozIExtensionAPIRequest
  • apiNamespace
  • apiName
  • apiObjectType
  • apiObjectId
  • callerSavedFrame
  • serviceWorkerInfo
  • args
  • normalizedArgs (R/W)
apiNamespaceapiName...
(XPCOM - C++)
(XPCOM - C++)
  • retrieve WorkerContextChild
  • validate and normalize arguments
  • check permissions
retrieve WorkerContextChild...
WebIDL
ChildAPIManager
(extends ChildAPIManager)
WebIDL...
WebIDL
ChildLocalAPIImpl
(extends ChildLocalAPIImpl)
WebIDL...
WebIDL
ChildObjectTypeIImpl
(extends ChildLocalAPIImpl)
WebIDL...
ProxyAPIImplementation
ProxyAPIImplementation
ProcessConduitsChild
ProcessConduitsChild
ProcessConduitsParent
ProcessConduitsParent
ext-APINAMESPACE.js
ExtensionAPI subclass
ext-APINAMESPACE.js...
PARENT
PROCESS
PARENT...
EXTENSIONS
CHILD PROCESS
EXTENSIONS...
DOM Worker
Thread
DOM Worker...
Main
Thread
Main...
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/toolkit/components/extensions/docs/wiring_up_new_webidl_bindings.rst b/toolkit/components/extensions/docs/wiring_up_new_webidl_bindings.rst new file mode 100644 index 0000000000..01c2498d6d --- /dev/null +++ b/toolkit/components/extensions/docs/wiring_up_new_webidl_bindings.rst @@ -0,0 +1,165 @@ +Wiring up new WebExtensions WebIDL files into mozilla-central +============================================================= + +Add a new entry in ``dom/bindings/Bindings.conf`` +------------------------------------------------- + +New WebIDL bindings should be added as new entries in ``dom/bindings/Bindings.conf``. The new entry should be +added in alphabetic order and nearby the other WebExtensions API bindings already listed in this config file +(look for the ``ExtensionBrowser`` webidl definition and the other existing WebIDL bindings related to the +WebExtensions APIs): + +.. code-block:: text + + # WebExtension API + ... + 'ExtensionRuntime': { + 'headerFile': 'mozilla/extensions/ExtensionRuntime.h', + 'nativeType': 'mozilla::extensions::ExtensionRuntime', + }, + +.. warning:: + + `mach build` will fail if the entries in `dom/bindings/Bindings.conf` are not in alphabetic order, + or if the `headerFile` referenced does not exist yet. + +Add a new entry in ``dom/webidl/moz.build`` +------------------------------------------- + +The new ``.webidl`` file has to be also listed in "dom/webidl/moz.build", it should be added in + +- the existing group of ``WEBIDL_FILES`` entries meant specifically for the WebExtensions API bindings +- or in the group of ``PREPROCESSED_WEBIDL_FILES`` entries meant specifically for the WebExtensions + API bindings, **if the generated `.webidl` includes preprocessing macros** (e.g. when part of an API + is not available in all builds, e.g. subset of APIs that are only available in Desktop builds). + +.. code-block:: text + + # WebExtensions API. + WEBIDL_FILES += [ + ... + "ExtensionRuntime.webidl", + ... + ] + + PREPROCESSED_WEBIDL_FILES += [ + ... + ] + +.. warning:: + + The group of PREPROCESSED_WERBIDL_FILES meant to list WebExtensions APIs ``.webidl`` files + may not exist yet (one will be added right after the existing `WEBIDL_FILES` when the first + preprocessed `.webidl` will be added). + + +Add new entries in ``toolkit/components/extensions/webidl-api/moz.build`` +------------------------------------------------------------------------- + +The new C++ files for the WebExtensions API binding needs to be added to ``toolkit/components/extensions/webidl-api/moz.build`` +to make them part of the build, The new ``.cpp`` file has to be added into the ``UNIFIED_SOURCES`` group +where the other WebIDL bindings are being listed. Similarly, the new ``.h`` counterpart has to be added to +``EXPORTS.mozilla.extensions`` (which ensures that the header file will be placed into the path set earlier +in ``dom/bindings/Bindings.conf``): + +.. code-block:: text + + # WebExtensions API namespaces. + UNIFIED_SOURCES += [ + ... + "ExtensionRuntime.cpp", + ... + ] + + EXPORTS.mozilla.extensions += [ + ... + "ExtensionRuntime.h", + ... + ] + +Wiring up the new API into ``dom/webidl/ExtensionBrowser.webidl`` +----------------------------------------------------------------- + +To make the new WebIDL bindings part of the ``browser`` global, a new attribute has to be added to +``dom/webidl/ExtensionBrowser.webidl``: + +.. code-block:: cpp + + // `browser.runtime` API namespace. + [Replaceable, SameObject, BinaryName="GetExtensionRuntime", + Func="mozilla::extensions::ExtensionRuntime::IsAllowed"] + readonly attribute ExtensionRuntime runtime; + +.. note:: + ``chrome`` is defined as an alias of the ``browser`` global, and so by adding the new attribute + into ``ExtensionBrowser` the same attribute will also be available in the ``chrome`` global. + Unlike the "Privileged JS"-based WebExtensions API, the ``chrome`` and ``browser`` APIs are + exactly the same and a the async methods return a Promise if no callback has been passed + (similarly to Safari versions where the WebExtensions APIs are supported). + +The additional attribute added into ``ExtensionBrowser.webidl`` will require some addition to the ``ExtensionBrowser`` +C++ class as defined in ``toolkit/components/extensions/webidl-api/ExtensionBrowser.h``: + +- the definition of a new corresponding **public method** (by convention named ``GetExtensionMyNamespace``) +- a ``RefPtr`` as a new **private data member named** (by convention named ``mExtensionMyNamespace``) + +.. code-block:: cpp + + ... + namespace extensions { + + ... + class ExtensionRuntime; + ... + + class ExtensionBrowser final : ... { + ... + RefPtr mExtensionRuntime; + ... + + public: + ... + ExtensionRuntime* GetExtensionRuntime(); + } + ... + + +And then in its ``toolkit/components/extensions/webidl-api/ExtensionBrowser.cpp`` counterpart: + +- the implementation of the new public method +- the addition of the new private member data ``RefPtr`` in the ``NS_IMPL_CYCLE_COLLECTION_UNLINK`` + and ``NS_IMPL_CYCLE_COLLECTION_TRAVERSE`` macros + +.. code-block:: cpp + + ... + #include "mozilla/extensions/ExtensionRuntime.h" + ... + NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(ExtensionBrowser) + ... + NS_IMPL_CYCLE_COLLECTION_UNLINK(mExtensionRuntime) + ... + NS_IMPL_CYCLE_COLLECTION_UNLINK_END + + NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(ExtensionBrowser) + ... + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mExtensionRuntime) + ... + NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + ... + + ExtensionRuntime* ExtensionBrowser::GetExtensionRuntime() { + if (!mExtensionRuntime) { + mExtensionRuntime = new ExtensionRuntime(mGlobal, this); + } + + return mExtensionRuntime + } + +.. warning:: + + Forgetting to add the new ``RefPtr`` into the cycle collection traverse and unlink macros + will not result in a build error, but it will result into a leak. + + Make sure to don't forget to double-check these macros, especially if some tests are failing + because of detected shutdown leaks. diff --git a/toolkit/components/extensions/dummy.xhtml b/toolkit/components/extensions/dummy.xhtml new file mode 100644 index 0000000000..8b6ada8a2a --- /dev/null +++ b/toolkit/components/extensions/dummy.xhtml @@ -0,0 +1,6 @@ + + + + diff --git a/toolkit/components/extensions/ext-browser-content.js b/toolkit/components/extensions/ext-browser-content.js new file mode 100644 index 0000000000..c431b6ccfd --- /dev/null +++ b/toolkit/components/extensions/ext-browser-content.js @@ -0,0 +1,275 @@ +/* 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/. */ + +/* eslint-env mozilla/frame-script */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +// Minimum time between two resizes. +const RESIZE_TIMEOUT = 100; + +const BrowserListener = { + init({ + allowScriptsToClose, + blockParser, + fixedWidth, + maxHeight, + maxWidth, + stylesheets, + isInline, + }) { + this.fixedWidth = fixedWidth; + this.stylesheets = stylesheets || []; + + this.isInline = isInline; + this.maxWidth = maxWidth; + this.maxHeight = maxHeight; + + this.blockParser = blockParser; + this.needsResize = fixedWidth || maxHeight || maxWidth; + + this.oldBackground = null; + + if (allowScriptsToClose) { + content.windowUtils.allowScriptsToClose(); + } + + if (this.blockParser) { + this.blockingPromise = new Promise(resolve => { + this.unblockParser = resolve; + }); + addEventListener("DOMDocElementInserted", this, true); + } + + addEventListener("load", this, true); + addEventListener("DOMWindowCreated", this, true); + addEventListener("DOMContentLoaded", this, true); + addEventListener("MozScrolledAreaChanged", this, true); + }, + + destroy() { + if (this.blockParser) { + removeEventListener("DOMDocElementInserted", this, true); + } + + removeEventListener("load", this, true); + removeEventListener("DOMWindowCreated", this, true); + removeEventListener("DOMContentLoaded", this, true); + removeEventListener("MozScrolledAreaChanged", this, true); + }, + + receiveMessage({ name, data }) { + if (name === "Extension:InitBrowser") { + this.init(data); + } else if (name === "Extension:UnblockParser") { + if (this.unblockParser) { + this.unblockParser(); + this.blockingPromise = null; + } + } else if (name === "Extension:GrabFocus") { + content.window.requestAnimationFrame(() => { + Services.focus.focusedWindow = content.window; + }); + } + }, + + loadStylesheets() { + let { windowUtils } = content; + + for (let url of this.stylesheets) { + windowUtils.addSheet( + ExtensionCommon.stylesheetMap.get(url), + windowUtils.AUTHOR_SHEET + ); + } + }, + + handleEvent(event) { + switch (event.type) { + case "DOMDocElementInserted": + if (this.blockingPromise) { + const doc = event.target; + const policy = doc?.nodePrincipal?.addonPolicy; + event.target.blockParsing(this.blockingPromise).then(() => { + policy?.weakExtension?.get()?.untrackBlockedParsingDocument(doc); + }); + policy?.weakExtension?.get()?.trackBlockedParsingDocument(doc); + } + break; + + case "DOMWindowCreated": + if (event.target === content.document) { + this.loadStylesheets(); + } + break; + + case "DOMContentLoaded": + if (event.target === content.document) { + sendAsyncMessage("Extension:BrowserContentLoaded", { + url: content.location.href, + }); + + if (this.needsResize) { + this.handleDOMChange(true); + } + } + break; + + case "load": + if (event.target.contentWindow === content) { + // For about:addons inline s, we currently receive a load + // event on the element, but no load or DOMContentLoaded + // events from the content window. + + // Inline browsers don't receive the "DOMWindowCreated" event, so this + // is a workaround to load the stylesheets. + if (this.isInline) { + this.loadStylesheets(); + } + sendAsyncMessage("Extension:BrowserContentLoaded", { + url: content.location.href, + }); + } else if (event.target !== content.document) { + break; + } + + if (!this.needsResize) { + break; + } + + // We use a capturing listener, so we get this event earlier than any + // load listeners in the content page. Resizing after a timeout ensures + // that we calculate the size after the entire event cycle has completed + // (unless someone spins the event loop, anyway), and hopefully after + // the content has made any modifications. + Promise.resolve().then(() => { + this.handleDOMChange(true); + }); + + // Mutation observer to make sure the panel shrinks when the content does. + new content.MutationObserver(this.handleDOMChange.bind(this)).observe( + content.document.documentElement, + { + attributes: true, + characterData: true, + childList: true, + subtree: true, + } + ); + break; + + case "MozScrolledAreaChanged": + if (this.needsResize) { + this.handleDOMChange(); + } + break; + } + }, + + // Resizes the browser to match the preferred size of the content (debounced). + handleDOMChange(ignoreThrottling = false) { + if (ignoreThrottling && this.resizeTimeout) { + clearTimeout(this.resizeTimeout); + this.resizeTimeout = null; + } + + if (this.resizeTimeout == null) { + this.resizeTimeout = setTimeout(() => { + try { + if (content) { + this._handleDOMChange("delayed"); + } + } finally { + this.resizeTimeout = null; + } + }, RESIZE_TIMEOUT); + + this._handleDOMChange(); + } + }, + + _handleDOMChange(detail) { + let doc = content.document; + + let body = doc.body; + if (!body || doc.compatMode === "BackCompat") { + // In quirks mode, the root element is used as the scroll frame, and the + // body lies about its scroll geometry, and returns the values for the + // root instead. + body = doc.documentElement; + } + + let result; + const zoom = content.browsingContext.fullZoom; + if (this.fixedWidth) { + // If we're in a fixed-width area (namely a slide-in subview of the main + // menu panel), we need to calculate the view height based on the + // preferred height of the content document's root scrollable element at the + // current width, rather than the complete preferred dimensions of the + // content window. + + // Compensate for any offsets (margin, padding, ...) between the scroll + // area of the body and the outer height of the document. + // This calculation is hard to get right for all cases, so take the lower + // number of the combination of all padding and margins of the document + // and body elements, or the difference between their heights. + let getHeight = elem => elem.getBoundingClientRect(elem).height; + let bodyPadding = getHeight(doc.documentElement) - getHeight(body); + + if (body !== doc.documentElement) { + let bs = content.getComputedStyle(body); + let ds = content.getComputedStyle(doc.documentElement); + + let p = + parseFloat(bs.marginTop) + + parseFloat(bs.marginBottom) + + parseFloat(ds.marginTop) + + parseFloat(ds.marginBottom) + + parseFloat(ds.paddingTop) + + parseFloat(ds.paddingBottom); + bodyPadding = Math.min(p, bodyPadding); + } + + let height = Math.ceil((body.scrollHeight + bodyPadding) * zoom); + + result = { height, detail }; + } else { + let background = content.windowUtils.canvasBackgroundColor; + if (background !== this.oldBackground) { + sendAsyncMessage("Extension:BrowserBackgroundChanged", { background }); + } + this.oldBackground = background; + + // Adjust the size of the browser based on its content's preferred size. + let w = {}, + h = {}; + docShell.docViewer.getContentSize( + this.maxWidth, + this.maxHeight, + /* prefWidth = */ 0, + w, + h + ); + + let width = Math.ceil(w.value * zoom); + let height = Math.ceil(h.value * zoom); + result = { width, height, detail }; + } + + sendAsyncMessage("Extension:BrowserResized", result); + }, +}; + +addMessageListener("Extension:InitBrowser", BrowserListener); +addMessageListener("Extension:UnblockParser", BrowserListener); +addMessageListener("Extension:GrabFocus", BrowserListener); + +// This is a temporary hack to prevent regressions (bug 1471327). +void content; diff --git a/toolkit/components/extensions/ext-toolkit.json b/toolkit/components/extensions/ext-toolkit.json new file mode 100644 index 0000000000..ebfb5c5933 --- /dev/null +++ b/toolkit/components/extensions/ext-toolkit.json @@ -0,0 +1,198 @@ +{ + "manifest": { + "schema": "chrome://extensions/content/schemas/extension_types.json", + "scopes": [] + }, + "alarms": { + "url": "chrome://extensions/content/parent/ext-alarms.js", + "schema": "chrome://extensions/content/schemas/alarms.json", + "scopes": ["addon_parent"], + "paths": [["alarms"]] + }, + "backgroundPage": { + "url": "chrome://extensions/content/parent/ext-backgroundPage.js", + "scopes": ["addon_parent"], + "manifest": ["background"] + }, + "browserSettings": { + "url": "chrome://extensions/content/parent/ext-browserSettings.js", + "schema": "chrome://extensions/content/schemas/browser_settings.json", + "scopes": ["addon_parent"], + "settings": true, + "paths": [["browserSettings"]] + }, + "clipboard": { + "url": "chrome://extensions/content/parent/ext-clipboard.js", + "schema": "chrome://extensions/content/schemas/clipboard.json", + "scopes": ["addon_parent"], + "paths": [["clipboard"]] + }, + "contentScripts": { + "url": "chrome://extensions/content/parent/ext-contentScripts.js", + "schema": "chrome://extensions/content/schemas/content_scripts.json", + "scopes": ["addon_parent"], + "paths": [["contentScripts"]] + }, + "contextualIdentities": { + "url": "chrome://extensions/content/parent/ext-contextualIdentities.js", + "schema": "chrome://extensions/content/schemas/contextual_identities.json", + "scopes": ["addon_parent"], + "settings": true, + "events": ["startup"], + "permissions": ["contextualIdentities"], + "paths": [["contextualIdentities"]] + }, + "cookies": { + "url": "chrome://extensions/content/parent/ext-cookies.js", + "schema": "chrome://extensions/content/schemas/cookies.json", + "scopes": ["addon_parent"], + "paths": [["cookies"]] + }, + "declarativeNetRequest": { + "url": "chrome://extensions/content/parent/ext-declarativeNetRequest.js", + "schema": "chrome://extensions/content/schemas/declarative_net_request.json", + "scopes": ["addon_parent"], + "manifest": ["declarative_net_request"], + "paths": [["declarativeNetRequest"]] + }, + "dns": { + "url": "chrome://extensions/content/parent/ext-dns.js", + "schema": "chrome://extensions/content/schemas/dns.json", + "scopes": ["addon_parent"], + "paths": [["dns"]] + }, + "downloads": { + "url": "chrome://extensions/content/parent/ext-downloads.js", + "schema": "chrome://extensions/content/schemas/downloads.json", + "scopes": ["addon_parent"], + "paths": [["downloads"]] + }, + "extension": { + "url": "chrome://extensions/content/parent/ext-extension.js", + "schema": "chrome://extensions/content/schemas/extension.json", + "scopes": ["addon_parent", "content_child"], + "paths": [["extension"]] + }, + "activityLog": { + "url": "chrome://extensions/content/parent/ext-activityLog.js", + "schema": "chrome://extensions/content/schemas/activity_log.json", + "scopes": ["addon_parent"], + "paths": [["activityLog"]] + }, + "i18n": { + "url": "chrome://extensions/content/parent/ext-i18n.js", + "schema": "chrome://extensions/content/schemas/i18n.json", + "scopes": ["addon_parent", "content_child", "devtools_child"], + "paths": [["i18n"]] + }, + "idle": { + "url": "chrome://extensions/content/parent/ext-idle.js", + "schema": "chrome://extensions/content/schemas/idle.json", + "scopes": ["addon_parent"], + "paths": [["idle"]] + }, + "management": { + "url": "chrome://extensions/content/parent/ext-management.js", + "schema": "chrome://extensions/content/schemas/management.json", + "scopes": ["addon_parent"], + "paths": [["management"]] + }, + "networkStatus": { + "url": "chrome://extensions/content/parent/ext-networkStatus.js", + "schema": "chrome://extensions/content/schemas/network_status.json", + "scopes": ["addon_parent"], + "paths": [["networkStatus"]] + }, + "notifications": { + "url": "chrome://extensions/content/parent/ext-notifications.js", + "schema": "chrome://extensions/content/schemas/notifications.json", + "scopes": ["addon_parent"], + "paths": [["notifications"]] + }, + "permissions": { + "url": "chrome://extensions/content/parent/ext-permissions.js", + "schema": "chrome://extensions/content/schemas/permissions.json", + "scopes": ["addon_parent"], + "paths": [["permissions"]] + }, + "privacy": { + "url": "chrome://extensions/content/parent/ext-privacy.js", + "schema": "chrome://extensions/content/schemas/privacy.json", + "scopes": ["addon_parent"], + "settings": true, + "paths": [["privacy"]] + }, + "protocolHandlers": { + "url": "chrome://extensions/content/parent/ext-protocolHandlers.js", + "schema": "chrome://extensions/content/schemas/extension_protocol_handlers.json", + "scopes": ["addon_parent"], + "manifest": ["protocol_handlers"] + }, + "proxy": { + "url": "chrome://extensions/content/parent/ext-proxy.js", + "schema": "chrome://extensions/content/schemas/proxy.json", + "scopes": ["addon_parent"], + "settings": true, + "paths": [["proxy"]], + "startupBlocking": true + }, + "runtime": { + "url": "chrome://extensions/content/parent/ext-runtime.js", + "schema": "chrome://extensions/content/schemas/runtime.json", + "scopes": ["addon_parent", "content_parent", "devtools_parent"], + "paths": [["runtime"]] + }, + "scripting": { + "url": "chrome://extensions/content/parent/ext-scripting.js", + "schema": "chrome://extensions/content/schemas/scripting.json", + "scopes": ["addon_parent"], + "paths": [["scripting"]] + }, + "storage": { + "url": "chrome://extensions/content/parent/ext-storage.js", + "schema": "chrome://extensions/content/schemas/storage.json", + "scopes": ["addon_parent", "content_parent", "devtools_parent"], + "paths": [["storage"]] + }, + "telemetry": { + "url": "chrome://extensions/content/parent/ext-telemetry.js", + "schema": "chrome://extensions/content/schemas/telemetry.json", + "scopes": ["addon_parent"], + "paths": [["telemetry"]] + }, + "test": { + "schema": "chrome://extensions/content/schemas/test.json", + "scopes": ["content_child"] + }, + "theme": { + "url": "chrome://extensions/content/parent/ext-theme.js", + "schema": "chrome://extensions/content/schemas/theme.json", + "scopes": ["addon_parent"], + "manifest": ["theme"], + "paths": [["theme"]] + }, + "userScripts": { + "url": "chrome://extensions/content/parent/ext-userScripts.js", + "schema": "chrome://extensions/content/schemas/user_scripts.json", + "scopes": ["addon_parent"], + "paths": [["userScripts"]] + }, + "userScriptsContent": { + "schema": "chrome://extensions/content/schemas/user_scripts_content.json", + "scopes": ["content_child"], + "paths": [["userScripts", "onBeforeScript"]] + }, + "webNavigation": { + "url": "chrome://extensions/content/parent/ext-webNavigation.js", + "schema": "chrome://extensions/content/schemas/web_navigation.json", + "scopes": ["addon_parent"], + "paths": [["webNavigation"]] + }, + "webRequest": { + "url": "chrome://extensions/content/parent/ext-webRequest.js", + "schema": "chrome://extensions/content/schemas/web_request.json", + "scopes": ["addon_parent"], + "paths": [["webRequest"]], + "startupBlocking": true + } +} diff --git a/toolkit/components/extensions/extIWebNavigation.idl b/toolkit/components/extensions/extIWebNavigation.idl new file mode 100644 index 0000000000..3095d93d9f --- /dev/null +++ b/toolkit/components/extensions/extIWebNavigation.idl @@ -0,0 +1,34 @@ +/* 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/. */ + +#include "nsISupports.idl" + +webidl BrowsingContext; +interface nsIURI; + +[scriptable, uuid(5cc10dac-cab3-41dd-b4ce-55e27c43cc40)] +interface extIWebNavigation : nsISupports +{ + void onDocumentChange(in BrowsingContext bc, + in jsval transitionData, + in nsIURI location); + + void onHistoryChange(in BrowsingContext bc, + in jsval transitionData, + in nsIURI location, + in bool isHistoryStateUpdated, + in bool isReferenceFragmentUpdated); + + void onStateChange(in BrowsingContext bc, + in nsIURI requestURI, + in nsresult status, + in unsigned long stateFlags); + + void onCreatedNavigationTarget(in BrowsingContext bc, + in BrowsingContext sourceBC, + in ACString url); + + void onDOMContentLoaded(in BrowsingContext bc, + in nsIURI documentURI); +}; diff --git a/toolkit/components/extensions/extensionProcessScriptLoader.js b/toolkit/components/extensions/extensionProcessScriptLoader.js new file mode 100644 index 0000000000..d6fbadf223 --- /dev/null +++ b/toolkit/components/extensions/extensionProcessScriptLoader.js @@ -0,0 +1,11 @@ +/* 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/. */ + +/* eslint-env mozilla/process-script */ + +"use strict"; + +ChromeUtils.importESModule( + "resource://gre/modules/ExtensionProcessScript.sys.mjs" +); diff --git a/toolkit/components/extensions/extensions-toolkit.manifest b/toolkit/components/extensions/extensions-toolkit.manifest new file mode 100644 index 0000000000..1bb8d3182e --- /dev/null +++ b/toolkit/components/extensions/extensions-toolkit.manifest @@ -0,0 +1,13 @@ +# scripts +category webextension-modules toolkit chrome://extensions/content/ext-toolkit.json + +category webextension-scripts a-toolkit chrome://extensions/content/parent/ext-toolkit.js +category webextension-scripts b-tabs-base chrome://extensions/content/parent/ext-tabs-base.js + +category webextension-scripts-content toolkit chrome://extensions/content/child/ext-toolkit.js +category webextension-scripts-devtools toolkit chrome://extensions/content/child/ext-toolkit.js +category webextension-scripts-addon toolkit chrome://extensions/content/child/ext-toolkit.js + +category webextension-schemas events chrome://extensions/content/schemas/events.json +category webextension-schemas native_manifest chrome://extensions/content/schemas/native_manifest.json +category webextension-schemas types chrome://extensions/content/schemas/types.json diff --git a/toolkit/components/extensions/jar.mn b/toolkit/components/extensions/jar.mn new file mode 100644 index 0000000000..3292078590 --- /dev/null +++ b/toolkit/components/extensions/jar.mn @@ -0,0 +1,65 @@ +# 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/. + +toolkit.jar: +% content extensions %content/extensions/ + content/extensions/dummy.xhtml + content/extensions/ext-browser-content.js + content/extensions/ext-toolkit.json + content/extensions/parent/ext-activityLog.js (parent/ext-activityLog.js) + content/extensions/parent/ext-alarms.js (parent/ext-alarms.js) + content/extensions/parent/ext-backgroundPage.js (parent/ext-backgroundPage.js) + content/extensions/parent/ext-browserSettings.js (parent/ext-browserSettings.js) + content/extensions/parent/ext-browsingData.js (parent/ext-browsingData.js) +#ifndef ANDROID + content/extensions/parent/ext-captivePortal.js (parent/ext-captivePortal.js) +#endif + content/extensions/parent/ext-contentScripts.js (parent/ext-contentScripts.js) + content/extensions/parent/ext-contextualIdentities.js (parent/ext-contextualIdentities.js) + content/extensions/parent/ext-clipboard.js (parent/ext-clipboard.js) + content/extensions/parent/ext-cookies.js (parent/ext-cookies.js) + content/extensions/parent/ext-declarativeNetRequest.js (parent/ext-declarativeNetRequest.js) + content/extensions/parent/ext-dns.js (parent/ext-dns.js) + content/extensions/parent/ext-downloads.js (parent/ext-downloads.js) + content/extensions/parent/ext-extension.js (parent/ext-extension.js) +#ifndef ANDROID + content/extensions/parent/ext-geckoProfiler.js (parent/ext-geckoProfiler.js) +#endif + content/extensions/parent/ext-i18n.js (parent/ext-i18n.js) +#ifndef ANDROID + content/extensions/parent/ext-identity.js (parent/ext-identity.js) +#endif + content/extensions/parent/ext-idle.js (parent/ext-idle.js) + content/extensions/parent/ext-management.js (parent/ext-management.js) + content/extensions/parent/ext-networkStatus.js (parent/ext-networkStatus.js) + content/extensions/parent/ext-notifications.js (parent/ext-notifications.js) + content/extensions/parent/ext-permissions.js (parent/ext-permissions.js) + content/extensions/parent/ext-privacy.js (parent/ext-privacy.js) + content/extensions/parent/ext-protocolHandlers.js (parent/ext-protocolHandlers.js) + content/extensions/parent/ext-proxy.js (parent/ext-proxy.js) + content/extensions/parent/ext-runtime.js (parent/ext-runtime.js) + content/extensions/parent/ext-scripting.js (parent/ext-scripting.js) + content/extensions/parent/ext-storage.js (parent/ext-storage.js) + content/extensions/parent/ext-tabs-base.js (parent/ext-tabs-base.js) + content/extensions/parent/ext-telemetry.js (parent/ext-telemetry.js) + content/extensions/parent/ext-theme.js (parent/ext-theme.js) + content/extensions/parent/ext-toolkit.js (parent/ext-toolkit.js) + content/extensions/parent/ext-userScripts.js (parent/ext-userScripts.js) + content/extensions/parent/ext-webRequest.js (parent/ext-webRequest.js) + content/extensions/parent/ext-webNavigation.js (parent/ext-webNavigation.js) + content/extensions/child/ext-backgroundPage.js (child/ext-backgroundPage.js) + content/extensions/child/ext-contentScripts.js (child/ext-contentScripts.js) + content/extensions/child/ext-declarativeNetRequest.js (child/ext-declarativeNetRequest.js) + content/extensions/child/ext-extension.js (child/ext-extension.js) +#ifndef ANDROID + content/extensions/child/ext-identity.js (child/ext-identity.js) +#endif + content/extensions/child/ext-runtime.js (child/ext-runtime.js) + content/extensions/child/ext-scripting.js (child/ext-scripting.js) + content/extensions/child/ext-storage.js (child/ext-storage.js) + content/extensions/child/ext-test.js (child/ext-test.js) + content/extensions/child/ext-toolkit.js (child/ext-toolkit.js) + content/extensions/child/ext-userScripts.js (child/ext-userScripts.js) + content/extensions/child/ext-userScripts-content.js (child/ext-userScripts-content.js) + content/extensions/child/ext-webRequest.js (child/ext-webRequest.js) diff --git a/toolkit/components/extensions/metrics.yaml b/toolkit/components/extensions/metrics.yaml new file mode 100644 index 0000000000..192b12f9f9 --- /dev/null +++ b/toolkit/components/extensions/metrics.yaml @@ -0,0 +1,708 @@ +# 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/. + +# Adding a new metric? We have docs for that! +# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html + +--- +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 +$tags: + - 'WebExtensions :: General' + +extensions: + use_remote_pref: + type: boolean + expires: never + lifetime: application + description: > + Corresponds to the value of `extensions.webextensions.remote` pref. + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1850351/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1850351#c2 + data_sensitivity: + - technical + + use_remote_policy: + type: boolean + expires: never + lifetime: application + description: > + Corresponds to the value of `WebExtensionPolicy.useRemoteWebExtensions`. + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1850351/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1850351#c2 + data_sensitivity: + - technical + + startup_cache_load_time: + type: timespan + time_unit: millisecond + expires: never + description: | + Time to load and deserialize the extensions startupCache data. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + - lgreco@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1767336/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1767336#c7 + data_sensitivity: + - technical + telemetry_mirror: EXTENSIONS_STARTUPCACHE_LOAD_TIME + + startup_cache_read_errors: + type: labeled_counter + expires: never + description: | + The number of times an unexpected error has been raised while reading + the extensions StartupCache file. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1767336/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1767336#c7 + data_sensitivity: + - technical + telemetry_mirror: EXTENSIONS_STARTUPCACHE_READ_ERRORS + + startup_cache_write_bytelength: + type: quantity + unit: bytes + expires: never + description: | + The amount of bytes written to the extensions StartupCache file. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1767336/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1767336#c7 + data_sensitivity: + - technical + telemetry_mirror: EXTENSIONS_STARTUPCACHE_WRITE_BYTELENGTH + + process_event: + type: labeled_counter + expires: never + description: | + Counters for how many times the extension process has crashed or been created. + The labels with "_fg" / "_bg" suffixes are only recorded in Android builds, + while the "created" and "crashed" labels are recorded on both Desktop and Android + builds. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1830157/ + - https://bugzilla.mozilla.org/1848223/ + - https://bugzilla.mozilla.org/1850350/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1830157#c7 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848223#c4 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1850350#c2 + data_sensitivity: + - technical + labels: + - crashed_bg + - crashed_fg + - created_bg + - created_fg + - crashed_over_threshold_bg + - crashed_over_threshold_fg + +extensions.apis.dnr: + + startup_cache_read_size: + type: memory_distribution + memory_unit: byte + expires: 126 + description: | + Amount of data read from the DNR startup cache file. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1803363/ + - https://bugzilla.mozilla.org/1850890/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1803363#c11 + data_sensitivity: + - technical + telemetry_mirror: WEBEXT_DNR_STARTUPCACHE_READ_BYTES + + startup_cache_read_time: + type: timing_distribution + time_unit: millisecond + expires: 126 + description: | + Amount of time it takes to read data into the DNR startup cache file. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1803363/ + - https://bugzilla.mozilla.org/1850890/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1803363#c11 + data_sensitivity: + - technical + telemetry_mirror: WEBEXT_DNR_STARTUPCACHE_READ_MS + + startup_cache_write_size: + type: memory_distribution + memory_unit: byte + expires: 126 + description: | + Amount of data written to the DNR startup cache file. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1803363/ + - https://bugzilla.mozilla.org/1850890/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1803363#c11 + data_sensitivity: + - technical + telemetry_mirror: WEBEXT_DNR_STARTUPCACHE_WRITE_BYTES + + startup_cache_write_time: + type: timing_distribution + time_unit: millisecond + expires: 126 + description: | + Amount of time it takes to write data into the DNR startup cache file. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1803363/ + - https://bugzilla.mozilla.org/1850890/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1803363#c11 + data_sensitivity: + - technical + telemetry_mirror: WEBEXT_DNR_STARTUPCACHE_WRITE_MS + + startup_cache_entries: + type: labeled_counter + expires: 126 + description: | + Counters for startup cache data hits or misses on initializating + DNR rules for extensions loaded on application startup. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1803363/ + - https://bugzilla.mozilla.org/1850890/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1803363#c11 + data_sensitivity: + - technical + labels: + - hit + - miss + telemetry_mirror: EXTENSIONS_APIS_DNR_STARTUP_CACHE_ENTRIES + + validate_rules_time: + type: timing_distribution + time_unit: millisecond + expires: 126 + description: | + Amount of time it takes to validate DNR rules of individual ruleset + when dynamic or static rulesets have been loaded from disk. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1803363/ + - https://bugzilla.mozilla.org/1850890/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1803363#c11 + data_sensitivity: + - technical + telemetry_mirror: WEBEXT_DNR_VALIDATE_RULES_MS + + evaluate_rules_time: + type: timing_distribution + time_unit: millisecond + expires: 126 + description: | + Amount of time it takes to evaluate DNR rules for one network request. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1803363/ + - https://bugzilla.mozilla.org/1850890/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1803363#c11 + data_sensitivity: + - technical + telemetry_mirror: WEBEXT_DNR_EVALUATE_RULES_MS + + evaluate_rules_count_max: + type: quantity + unit: rules + expires: 126 + description: | + Max amount of DNR rules being evaluated. + lifetime: ping + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1803363/ + - https://bugzilla.mozilla.org/1850890/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1803363#c11 + data_sensitivity: + - technical + telemetry_mirror: EXTENSIONS_APIS_DNR_EVALUATE_RULES_COUNT_MAX + +extensions.data: + + migrate_result: + type: event + description: | + These events are sent when an extension is migrating its data to the new + IndexedDB backend. + bugs: + - https://bugzilla.mozilla.org/1470213 + - https://bugzilla.mozilla.org/1553297 + - https://bugzilla.mozilla.org/1590736 + - https://bugzilla.mozilla.org/1630596 + - https://bugzilla.mozilla.org/1672570 + - https://bugzilla.mozilla.org/1714251 + - https://bugzilla.mozilla.org/1749878 + - https://bugzilla.mozilla.org/1781974 + - https://bugzilla.mozilla.org/1817100 + - https://bugzilla.mozilla.org/1861295 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1470213#c15 + notification_emails: + - addons-dev-internal@mozilla.com + extra_keys: + addon_id: + description: Id of the addon. + type: string + backend: + description: The selected backend ("JSONFile" / "IndexedDB"). + type: string + data_migrated: + description: The old extension data has been migrated ("y" / "n"). + type: string + error_name: + description: | + A DOMException error name if any ("OtherError" for unknown errors). + The error has been fatal if the `backend` extra key is "JSONFile", + otherwise it is a non fatal error which didn't prevented the + extension from switching to the IndexedDB backend. + type: string + has_jsonfile: + description: The extension has a JSONFile ("y" / "n"). + type: string + has_olddata: + description: Extension had some data stored in JSONFile ("y" / "n"). + type: string + expires: 132 + + storage_local_error: + type: event + description: | + These events are collected when an extension triggers an unexpected error + while running a storage.local API call (e.g. because of some underlying + QuotaManager and/or IndexedDB error). + bugs: + - https://bugzilla.mozilla.org/1606903 + - https://bugzilla.mozilla.org/1649948 + - https://bugzilla.mozilla.org/1689255 + - https://bugzilla.mozilla.org/1730038 + - https://bugzilla.mozilla.org/1763523 + - https://bugzilla.mozilla.org/1811148 + - https://bugzilla.mozilla.org/1861297 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1606903#c3 + notification_emails: + - addons-dev-internal@mozilla.com + extra_keys: + addon_id: + description: Id of the addon. + type: string + method: + description: The storage.local API method name. + type: string + error_name: + description: | + A DOMException error name if any ("OtherError" for unknown errors). + type: string + expires: 132 + +extensions.quarantined_domains: + + listsize: + type: quantity + unit: domains + description: > + Number of domains listed in the quarantined domains list pref for the client during + this session. + lifetime: application + expires: 130 + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1840615/ + - https://bugzilla.mozilla.org/1866199/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1840615 + data_sensitivity: + - technical + telemetry_mirror: EXTENSIONS_QUARANTINEDDOMAINS_LISTSIZE + + listhash: + type: string + description: > + SHA1 cryptographic hash of the quarantined domains string pref. + lifetime: application + expires: 130 + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1841683/ + - https://bugzilla.mozilla.org/1866199/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1840615#c2 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1841683 + data_sensitivity: + - technical + telemetry_mirror: EXTENSIONS_QUARANTINEDDOMAINS_LISTHASH + + remotehash: + type: string + description: > + SHA1 cryptographic hash of the quarantined domains string pref as it was + set based on the value got synced from the RemoteSettings collection. + AMRemoteSettings will be re-processing the entries on the next application + startup and so this metric lifetime can be set to application and expect + it to be always set to the value got from the RemoteSettings collection. + lifetime: application + expires: 130 + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1841683/ + - https://bugzilla.mozilla.org/1866199/ + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1841683 + data_sensitivity: + - technical + telemetry_mirror: EXTENSIONS_QUARANTINEDDOMAINS_REMOTEHASH + +extensions.counters: + + browser_action_preload_result: + type: labeled_counter + expires: never + description: | + Number of times an event page hit the idle timeout and results in one of the labels. + # Keep these labels in sync with the ones in WEBEXT_BROWSERACTION_POPUP_PRELOAD_RESULT_COUNT + # as defined in Histograms.json + labels: + - popupShown + - clearAfterHover + - clearAfterMousedown + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1297167 + - https://bugzilla.mozilla.org/1513556 + - https://bugzilla.mozilla.org/1578225 + - https://bugzilla.mozilla.org/1623315 + - https://bugzilla.mozilla.org/1666980 + - https://bugzilla.mozilla.org/1706839 + - https://bugzilla.mozilla.org/1745271 + - https://bugzilla.mozilla.org/1777402 + - https://bugzilla.mozilla.org/1811155 + - https://bugzilla.mozilla.org/1861303 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + + event_page_idle_result: + type: labeled_counter + expires: never + description: | + Number of times an event page hit the idle timeout and results in one of the labels. + # Keep these labels in sync with the ones in WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT + # as defined in Histograms.json + labels: + - suspend + - reset_other + - reset_event + - reset_listeners + - reset_nativeapp + - reset_streamfilter + - reset_parentapicall + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1787940 + - https://bugzilla.mozilla.org/1817103 + - https://bugzilla.mozilla.org/1861303 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + +extensions.timing: + + background_page_load: + type: timing_distribution + time_unit: millisecond + expires: never + description: | + Amount of time it takes to load a WebExtensions background page, from when the + build function is called to when the page has finished processing the onload event. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1353172 + - https://bugzilla.mozilla.org/1489524 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + + browser_action_popup_open: + type: timing_distribution + time_unit: millisecond + expires: never + description: | + Amount of time it takes for a BrowserAction popup to open. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1297167 + - https://bugzilla.mozilla.org/1513556 + - https://bugzilla.mozilla.org/1578225 + - https://bugzilla.mozilla.org/1623315 + - https://bugzilla.mozilla.org/1666980 + - https://bugzilla.mozilla.org/1706839 + - https://bugzilla.mozilla.org/1745271 + - https://bugzilla.mozilla.org/1777402 + - https://bugzilla.mozilla.org/1811155 + - https://bugzilla.mozilla.org/1861303 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + + content_script_injection: + type: timing_distribution + time_unit: millisecond + expires: never + description: | + Amount of time it takes for content scripts from a WebExtension to be injected into a window. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1356323 + - https://bugzilla.mozilla.org/1489524 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + + event_page_running_time: + type: custom_distribution + unit: ms + range_min: 1 + range_max: 60000 + bucket_count: 100 + histogram_type: exponential + expires: never + description: | + Amount of time (keyed by addon id) that an event page has been running before being suspended, + or the entire addon shutdown. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1787940 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + + extension_startup: + type: timing_distribution + time_unit: millisecond + expires: never + description: | + Amount of time it takes for a WebExtension to start up, from when the + startup function is called to when the startup promise resolves. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1353171 + - https://bugzilla.mozilla.org/1489524 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + + page_action_popup_open: + type: timing_distribution + time_unit: millisecond + expires: never + description: | + Amount of time it takes for a PageAction popup to open. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1297167 + - https://bugzilla.mozilla.org/1513556 + - https://bugzilla.mozilla.org/1578225 + - https://bugzilla.mozilla.org/1623315 + - https://bugzilla.mozilla.org/1666980 + - https://bugzilla.mozilla.org/1706839 + - https://bugzilla.mozilla.org/1745271 + - https://bugzilla.mozilla.org/1777402 + - https://bugzilla.mozilla.org/1811155 + - https://bugzilla.mozilla.org/1861303 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + + storage_local_get_json: + type: timing_distribution + time_unit: millisecond + expires: 128 + description: | + Amount of time it takes to perform a get via storage.local using the JSONFile backend. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1371398 + - https://bugzilla.mozilla.org/1513556 + - https://bugzilla.mozilla.org/1578225 + - https://bugzilla.mozilla.org/1623315 + - https://bugzilla.mozilla.org/1666980 + - https://bugzilla.mozilla.org/1706839 + - https://bugzilla.mozilla.org/1745271 + - https://bugzilla.mozilla.org/1777402 + - https://bugzilla.mozilla.org/1811155 + - https://bugzilla.mozilla.org/1861303 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + + storage_local_set_json: + type: timing_distribution + time_unit: millisecond + expires: 128 + description: | + Amount of time it takes to perform a set via storage.local using the JSONFile backend. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1371398 + - https://bugzilla.mozilla.org/1513556 + - https://bugzilla.mozilla.org/1578225 + - https://bugzilla.mozilla.org/1623315 + - https://bugzilla.mozilla.org/1666980 + - https://bugzilla.mozilla.org/1706839 + - https://bugzilla.mozilla.org/1745271 + - https://bugzilla.mozilla.org/1777402 + - https://bugzilla.mozilla.org/1811155 + - https://bugzilla.mozilla.org/1861303 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + + storage_local_get_idb: + type: timing_distribution + time_unit: millisecond + expires: never + description: | + Amount of time it takes to perform a get via storage.local using the IndexedDB backend. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1465120 + - https://bugzilla.mozilla.org/1513556 + - https://bugzilla.mozilla.org/1578225 + - https://bugzilla.mozilla.org/1623315 + - https://bugzilla.mozilla.org/1666980 + - https://bugzilla.mozilla.org/1706839 + - https://bugzilla.mozilla.org/1745271 + - https://bugzilla.mozilla.org/1777402 + - https://bugzilla.mozilla.org/1811155 + - https://bugzilla.mozilla.org/1861303 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical + + storage_local_set_idb: + type: timing_distribution + time_unit: millisecond + expires: never + description: | + Amount of time it takes to perform a set via storage.local using the Indexed backend. + lifetime: application + notification_emails: + - addons-dev-internal@mozilla.com + bugs: + - https://bugzilla.mozilla.org/1465120 + - https://bugzilla.mozilla.org/1513556 + - https://bugzilla.mozilla.org/1578225 + - https://bugzilla.mozilla.org/1623315 + - https://bugzilla.mozilla.org/1666980 + - https://bugzilla.mozilla.org/1706839 + - https://bugzilla.mozilla.org/1745271 + - https://bugzilla.mozilla.org/1777402 + - https://bugzilla.mozilla.org/1811155 + - https://bugzilla.mozilla.org/1861303 + - https://bugzilla.mozilla.org/1820158 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1820158#c8 + data_sensitivity: + - technical diff --git a/toolkit/components/extensions/moz.build b/toolkit/components/extensions/moz.build new file mode 100644 index 0000000000..8b75567bb3 --- /dev/null +++ b/toolkit/components/extensions/moz.build @@ -0,0 +1,148 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + + +with Files("**"): + BUG_COMPONENT = ("WebExtensions", "General") + +EXTRA_JS_MODULES += [ + "ConduitsChild.sys.mjs", + "ConduitsParent.sys.mjs", + "Extension.sys.mjs", + "ExtensionActions.sys.mjs", + "ExtensionActivityLog.sys.mjs", + "ExtensionChild.sys.mjs", + "ExtensionChildDevToolsUtils.sys.mjs", + "ExtensionCommon.sys.mjs", + "ExtensionContent.sys.mjs", + "ExtensionDNR.sys.mjs", + "ExtensionDNRLimits.sys.mjs", + "ExtensionDNRStore.sys.mjs", + "ExtensionPageChild.sys.mjs", + "ExtensionParent.sys.mjs", + "ExtensionPermissionMessages.sys.mjs", + "ExtensionPermissions.sys.mjs", + "ExtensionPreferencesManager.sys.mjs", + "ExtensionProcessScript.sys.mjs", + "extensionProcessScriptLoader.js", + "ExtensionScriptingStore.sys.mjs", + "ExtensionSettingsStore.sys.mjs", + "ExtensionShortcuts.sys.mjs", + "ExtensionStorage.sys.mjs", + "ExtensionStorageIDB.sys.mjs", + "ExtensionStorageSync.sys.mjs", + "ExtensionStorageSyncKinto.sys.mjs", + "ExtensionTelemetry.sys.mjs", + "ExtensionUtils.sys.mjs", + "ExtensionWorkerChild.sys.mjs", + "FindContent.sys.mjs", + "MatchURLFilters.sys.mjs", + "MessageManagerProxy.sys.mjs", + "NativeManifests.sys.mjs", + "NativeMessaging.sys.mjs", + "ProxyChannelFilter.sys.mjs", + "Schemas.sys.mjs", + "WebNavigation.sys.mjs", + "WebNavigationFrames.sys.mjs", +] + +EXTRA_COMPONENTS += [ + "extensions-toolkit.manifest", +] + +TESTING_JS_MODULES += [ + "ExtensionTestCommon.sys.mjs", + "ExtensionXPCShellUtils.sys.mjs", + "MessageChannel.sys.mjs", + "test/xpcshell/data/TestWorkerWatcherChild.sys.mjs", + "test/xpcshell/data/TestWorkerWatcherParent.sys.mjs", +] + +DIRS += [ + "schemas", + "storage", + "webidl-api", + "webrequest", +] + +IPDL_SOURCES += [ + "PExtensions.ipdl", +] + +XPIDL_SOURCES += [ + "extIWebNavigation.idl", + "mozIExtensionAPIRequestHandling.idl", + "mozIExtensionProcessScript.idl", +] + +XPIDL_MODULE = "webextensions" + +EXPORTS.mozilla = [ + "ExtensionPolicyService.h", +] + +EXPORTS.mozilla.extensions = [ + "DocumentObserver.h", + "ExtensionsChild.h", + "ExtensionsParent.h", + "MatchGlob.h", + "MatchPattern.h", + "WebExtensionContentScript.h", + "WebExtensionPolicy.h", +] + +UNIFIED_SOURCES += [ + "ExtensionPolicyService.cpp", + "ExtensionsChild.cpp", + "ExtensionsParent.cpp", + "MatchPattern.cpp", + "WebExtensionPolicy.cpp", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +FINAL_LIBRARY = "xul" + + +JAR_MANIFESTS += ["jar.mn"] + +BROWSER_CHROME_MANIFESTS += [ + "test/browser/browser.toml", +] + +MOCHITEST_MANIFESTS += [ + "test/mochitest/mochitest-remote.toml", + "test/mochitest/mochitest.toml", +] +MOCHITEST_CHROME_MANIFESTS += ["test/mochitest/chrome.toml"] +XPCSHELL_TESTS_MANIFESTS += [ + "test/xpcshell/native_messaging.toml", + "test/xpcshell/xpcshell-e10s.toml", + "test/xpcshell/xpcshell-legacy-ep.toml", + "test/xpcshell/xpcshell-remote.toml", + "test/xpcshell/xpcshell.toml", +] + +# Only include tests that requires the WebExtensions WebIDL API bindings +# in builds where they are enabled (currently only on Nightly builds). +if CONFIG["MOZ_WEBEXT_WEBIDL_ENABLED"]: + BROWSER_CHROME_MANIFESTS += ["test/browser/browser-serviceworker.toml"] + MARIONETTE_MANIFESTS += ["test/marionette/manifest-serviceworker.toml"] + XPCSHELL_TESTS_MANIFESTS += [ + "test/xpcshell/webidl-api/xpcshell.toml", + "test/xpcshell/xpcshell-serviceworker.toml", + ] + MOCHITEST_MANIFESTS += ["test/mochitest/mochitest-serviceworker.toml"] + + +SPHINX_TREES["webextensions"] = "docs" + +with Files("docs/**"): + SCHEDULES.exclusive = ["docs"] + +include("/ipc/chromium/chromium-config.mozbuild") diff --git a/toolkit/components/extensions/mozIExtensionAPIRequestHandling.idl b/toolkit/components/extensions/mozIExtensionAPIRequestHandling.idl new file mode 100644 index 0000000000..0a2e3c7a5d --- /dev/null +++ b/toolkit/components/extensions/mozIExtensionAPIRequestHandling.idl @@ -0,0 +1,192 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface nsIPrincipal; + +[scriptable, builtinclass, uuid(e6862533-8844-4207-a6ab-04748a29d859)] +interface mozIExtensionServiceWorkerInfo : nsISupports +{ + readonly attribute nsIPrincipal principal; + readonly attribute AString scriptURL; + readonly attribute AString clientInfoId; + readonly attribute unsigned long long descriptorId; +}; + +[scriptable, uuid(876d45db-5c1b-4c9b-9148-1c86b33d120b)] +interface mozIExtensionListenerCallOptions : nsISupports +{ + cenum APIObjectType: 8 { + // Default: no api object is prepended to the event listener call arguments. + NONE, + + // A runtime.Port instance is prepended to the event listener call arguments. + RUNTIME_PORT, + }; + + readonly attribute mozIExtensionListenerCallOptions_APIObjectType apiObjectType; + + // An optional javascript object that should provide the attributes expected + // by related apiObjectType webidl dictionary type (e.g. ExtensionPortDescriptor + // if apiObjectType is RUNTIME_PORT). + readonly attribute jsval apiObjectDescriptor; + + // An optional boolean to be set to true if the api object should be + // prepended to the rest of the call arguments (by default it is appended). + readonly attribute bool apiObjectPrepended; + + cenum CallbackType: 8 { + // Default: no callback argument is passed to the call to the event listener. + CALLBACK_NONE, + + // The event listener will be called with a function as the last call parameter + // that behaves like the runtime.onMessage's sendResponse parameter: + // + // - if the event listener already returned false, sendResponse calls are ignored + // (if it has not been called already) + // - if the event listener already returned true, the first sendResponse call + // resolves the promise returned by the mozIExtensionCallback method call + // - if the event listener already returned a Promise, sendResponse calls + // are ignored + CALLBACK_SEND_RESPONSE, + }; + + attribute mozIExtensionListenerCallOptions_CallbackType callbackType; +}; + +[scriptable, builtinclass, uuid(e68e3c19-1b35-4112-8faa-5c5b84086a5b)] +interface mozIExtensionEventListener : nsISupports +{ + [implicit_jscontext, can_run_script] + Promise callListener( + in Array args, + [optional] in mozIExtensionListenerCallOptions listenerCallOptions); +}; + +[scriptable, builtinclass, uuid(0fee1c8f-e363-46a6-bd0c-d3c3338e2534)] +interface mozIExtensionAPIRequest : nsISupports +{ + AUTF8String toString(); + + // Determine what the caller and receiver should expect, e.g. what should + // be provided to the API request handler and what to expect it to return. + cenum RequestType: 8 { + CALL_FUNCTION, + CALL_FUNCTION_NO_RETURN, + CALL_FUNCTION_ASYNC, + ADD_LISTENER, + REMOVE_LISTENER, + GET_PROPERTY, + }; + + // The type of the request. + readonly attribute AUTF8String requestType; + + // WebExtension API namespace (e.g. tabs, runtime, webRequest, ...) + readonly attribute AUTF8String apiNamespace; + // method or event name + readonly attribute AUTF8String apiName; + + // API object type (e.g. Port, Panel, ...) and its unique id, this + // properties are used by API requests originated by an API object + // instance (like a runtime Port returned by browser.runtime.connect). + readonly attribute AUTF8String apiObjectType; + readonly attribute AUTF8String apiObjectId; + + // An array of API call arguments. + [implicit_jscontext] readonly attribute jsval args; + + // A property to store on the request objects the arguments normalized + // based on the API jsonschema, so that they are being propagated along + // with the API request object. + // TODO: change this attribute to a readonly attribute if we moved + // the parameters validation and normalization to the C++ layer. + [implicit_jscontext] attribute jsval normalizedArgs; + + // The caller SavedFrame (only set for calls originated off of the main thread + // from a service worker). + [implicit_jscontext] readonly attribute jsval callerSavedFrame; + + // Set for requests coming from an extension service worker. + readonly attribute mozIExtensionServiceWorkerInfo serviceWorkerInfo; + + // Set for `addListener`/`removeListener` API requests. + readonly attribute mozIExtensionEventListener eventListener; +}; + +[scriptable, uuid(59fd4097-d88e-40fd-8664-fedd8ab67ab6)] +interface mozIExtensionAPIRequestResult : nsISupports +{ + cenum ResultType: 8 { + // The result is a value to be returned as a result for the API request. + // The value attribute can be set to: + // - a structured clonable result value (which may be the result of the + // API call itself, or be used by the API method webidl implementation + // to initialize a webidl object to return to the caller, e.g. + // ExtensionPort returned by a call to browser.runtime.connect()) + // - an error object (which also include internally converted to and from + // ClonedErrorHolder to make it structured clonable). + // - a Promise (which can be resolved to a structured clonable value or + // an error object). + RETURN_VALUE, + + // The result is an error object that should be thrown as an extension error + // to the caller on the thread that originated the request. + EXTENSION_ERROR, + }; + + readonly attribute mozIExtensionAPIRequestResult_ResultType type; + readonly attribute jsval value; +}; + +[scriptable, uuid(0c61bd33-0557-43a2-9497-96c449f39e33)] +interface mozIExtensionAPIRequestHandler : nsISupports +{ + /** + * Handle an API request originated from the WebExtensions webidl API + * bindings. + * + * @param extension An instance of the WebExtensionPolicy webidl interface. + * @param apiRequest An instance of the mozIExtensionAPIRequest xpcom interface. + * + * @return mozIExtensionAPIRequestResult + * JS value returned by the API request handler, may contain a value + * (the result of the API call or a WebIDL dictionary that is used to + * initialize WebIDL-based API object, e.g. ExtensionPort) or + * an Error to be throw on the thread that originated the request. + */ + void handleAPIRequest(in nsISupports extension, + in mozIExtensionAPIRequest apiRequest, + [optional, retval] out mozIExtensionAPIRequestResult apiRequestResult); + + /** + * A method called when an extension background service worker is initialized and + * ready to execute its main script. + * + * @param extension An instance of the WebExtensionPolicy webidl interface. + * @param serviceWorkerInfo + */ + void initExtensionWorker(in nsISupports extension, + in mozIExtensionServiceWorkerInfo serviceWorkerInfo); + + /** + * A method called when an extension background service worker has loaded its + * main script. + * + * @param extension An instance of the WebExtensionPolicy webidl interface. + * @param serviceWorkerDescriptorId + */ + void onExtensionWorkerLoaded(in nsISupports extension, + in unsigned long long serviceWorkerDescriptorId); + + /** + * A method called when an extension background service worker is destroyed. + * + * @param extension An instance of the WebExtensionPolicy webidl interface. + * @param serviceWorkerDescriptorId + */ + void onExtensionWorkerDestroyed(in nsISupports extension, + in unsigned long long serviceWorkerDescriptorId); +}; diff --git a/toolkit/components/extensions/mozIExtensionProcessScript.idl b/toolkit/components/extensions/mozIExtensionProcessScript.idl new file mode 100644 index 0000000000..84b33a9d02 --- /dev/null +++ b/toolkit/components/extensions/mozIExtensionProcessScript.idl @@ -0,0 +1,21 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface mozIDOMWindow; +webidl Document; +webidl WebExtensionContentScript; + +[scriptable, uuid(6b09dc51-6caa-4ca7-9d6d-30c87258a630)] +interface mozIExtensionProcessScript : nsISupports +{ + void preloadContentScript(in nsISupports contentScript); + + Promise loadContentScript(in WebExtensionContentScript contentScript, + in mozIDOMWindow window); + + void initExtensionDocument(in nsISupports extension, in Document doc, + in bool privileged); +}; diff --git a/toolkit/components/extensions/parent/.eslintrc.js b/toolkit/components/extensions/parent/.eslintrc.js new file mode 100644 index 0000000000..2af2a2b34b --- /dev/null +++ b/toolkit/components/extensions/parent/.eslintrc.js @@ -0,0 +1,32 @@ +/* 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"; + +module.exports = { + globals: { + CONTAINER_STORE: true, + DEFAULT_STORE: true, + EventEmitter: true, + EventManager: true, + InputEventManager: true, + PRIVATE_STORE: true, + TabBase: true, + TabManagerBase: true, + TabTrackerBase: true, + WindowBase: true, + WindowManagerBase: true, + WindowTrackerBase: true, + getUserContextIdForCookieStoreId: true, + getContainerForCookieStoreId: true, + getCookieStoreIdForContainer: true, + getCookieStoreIdForOriginAttributes: true, + getCookieStoreIdForTab: true, + getOriginAttributesPatternForCookieStoreId: true, + isContainerCookieStoreId: true, + isDefaultCookieStoreId: true, + isPrivateCookieStoreId: true, + isValidCookieStoreId: true, + }, +}; diff --git a/toolkit/components/extensions/parent/ext-activityLog.js b/toolkit/components/extensions/parent/ext-activityLog.js new file mode 100644 index 0000000000..2b0c68614e --- /dev/null +++ b/toolkit/components/extensions/parent/ext-activityLog.js @@ -0,0 +1,38 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionActivityLog: "resource://gre/modules/ExtensionActivityLog.sys.mjs", + ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", +}); + +this.activityLog = class extends ExtensionAPI { + getAPI(context) { + return { + activityLog: { + onExtensionActivity: new ExtensionCommon.EventManager({ + context, + name: "activityLog.onExtensionActivity", + register: (fire, id) => { + // A logger cannot log itself. + if (id === context.extension.id) { + throw new ExtensionUtils.ExtensionError( + "Extension cannot monitor itself." + ); + } + function handler(details) { + fire.async(details); + } + + ExtensionActivityLog.addListener(id, handler); + return () => { + ExtensionActivityLog.removeListener(id, handler); + }; + }, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-alarms.js b/toolkit/components/extensions/parent/ext-alarms.js new file mode 100644 index 0000000000..1eea8397e2 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-alarms.js @@ -0,0 +1,161 @@ +/* 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"; + +// The ext-* files are imported into the same scopes. +/* import-globals-from ext-toolkit.js */ + +// Manages an alarm created by the extension (alarms API). +class Alarm { + constructor(api, name, alarmInfo) { + this.api = api; + this.name = name; + this.when = alarmInfo.when; + this.delayInMinutes = alarmInfo.delayInMinutes; + this.periodInMinutes = alarmInfo.periodInMinutes; + this.canceled = false; + + let delay, scheduledTime; + if (this.when) { + scheduledTime = this.when; + delay = this.when - Date.now(); + } else { + if (!this.delayInMinutes) { + this.delayInMinutes = this.periodInMinutes; + } + delay = this.delayInMinutes * 60 * 1000; + scheduledTime = Date.now() + delay; + } + + this.scheduledTime = scheduledTime; + + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + delay = delay > 0 ? delay : 0; + timer.init(this, delay, Ci.nsITimer.TYPE_ONE_SHOT); + this.timer = timer; + } + + clear() { + this.timer.cancel(); + this.api.alarms.delete(this.name); + this.canceled = true; + } + + observe(subject, topic, data) { + if (this.canceled) { + return; + } + + for (let callback of this.api.callbacks) { + callback(this); + } + + if (!this.periodInMinutes) { + this.clear(); + return; + } + + let delay = this.periodInMinutes * 60 * 1000; + this.scheduledTime = Date.now() + delay; + this.timer.init(this, delay, Ci.nsITimer.TYPE_ONE_SHOT); + } + + get data() { + return { + name: this.name, + scheduledTime: this.scheduledTime, + periodInMinutes: this.periodInMinutes, + }; + } +} + +this.alarms = class extends ExtensionAPIPersistent { + constructor(extension) { + super(extension); + + this.alarms = new Map(); + this.callbacks = new Set(); + } + + onShutdown() { + for (let alarm of this.alarms.values()) { + alarm.clear(); + } + } + + PERSISTENT_EVENTS = { + onAlarm({ fire }) { + let callback = alarm => { + fire.sync(alarm.data); + }; + + this.callbacks.add(callback); + + return { + unregister: () => { + this.callbacks.delete(callback); + }, + convert(_fire, context) { + fire = _fire; + }, + }; + }, + }; + + getAPI(context) { + const self = this; + + return { + alarms: { + create: function (name, alarmInfo) { + name = name || ""; + if (self.alarms.has(name)) { + self.alarms.get(name).clear(); + } + let alarm = new Alarm(self, name, alarmInfo); + self.alarms.set(alarm.name, alarm); + }, + + get: function (name) { + name = name || ""; + if (self.alarms.has(name)) { + return Promise.resolve(self.alarms.get(name).data); + } + return Promise.resolve(); + }, + + getAll: function () { + let result = Array.from(self.alarms.values(), alarm => alarm.data); + return Promise.resolve(result); + }, + + clear: function (name) { + name = name || ""; + if (self.alarms.has(name)) { + self.alarms.get(name).clear(); + return Promise.resolve(true); + } + return Promise.resolve(false); + }, + + clearAll: function () { + let cleared = false; + for (let alarm of self.alarms.values()) { + alarm.clear(); + cleared = true; + } + return Promise.resolve(cleared); + }, + + onAlarm: new EventManager({ + context, + module: "alarms", + event: "onAlarm", + extensionApi: self, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-backgroundPage.js b/toolkit/components/extensions/parent/ext-backgroundPage.js new file mode 100644 index 0000000000..155220c67a --- /dev/null +++ b/toolkit/components/extensions/parent/ext-backgroundPage.js @@ -0,0 +1,1116 @@ +/* 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"; + +var { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" +); +var { + HiddenExtensionPage, + promiseBackgroundViewLoaded, + watchExtensionWorkerContextLoaded, +} = ExtensionParent; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(this, "serviceWorkerManager", () => { + return Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "backgroundIdleTimeout", + "extensions.background.idle.timeout", + 30000, + null, + // Minimum 100ms, max 5min + delay => Math.min(Math.max(delay, 100), 5 * 60 * 1000) +); + +// Pref used in tests to assert background page state set to +// stopped on an extension process crash. +XPCOMUtils.defineLazyPreferenceGetter( + this, + "disableRestartPersistentAfterCrash", + "extensions.background.disableRestartPersistentAfterCrash", + false +); + +// eslint-disable-next-line mozilla/reject-importGlobalProperties +Cu.importGlobalProperties(["DOMException"]); + +function notifyBackgroundScriptStatus(addonId, isRunning) { + // Notify devtools when the background scripts is started or stopped + // (used to show the current status in about:debugging). + const subject = { addonId, isRunning }; + Services.obs.notifyObservers(subject, "extension:background-script-status"); +} + +// Same as nsITelemetry msSinceProcessStartExcludingSuspend but returns +// undefined instead of throwing an extension. +function msSinceProcessStartExcludingSuspend() { + let now; + try { + now = Services.telemetry.msSinceProcessStartExcludingSuspend(); + } catch (err) { + Cu.reportError(err); + } + return now; +} + +/** + * Background Page state transitions: + * + * ------> STOPPED <------- + * | | | + * | v | + * | STARTING >------| + * | | | + * | v ^ + * |----< RUNNING ----> SUSPENDING + * ^ v + * |------------| + * + * STARTING: The background is being built. + * RUNNING: The background is running. + * SUSPENDING: The background is suspending, runtime.onSuspend will be called. + * STOPPED: The background is not running. + * + * For persistent backgrounds, SUSPENDING is not used. + * + * See BackgroundContextOwner for the exact relation. + */ +const BACKGROUND_STATE = { + STARTING: "starting", + RUNNING: "running", + SUSPENDING: "suspending", + STOPPED: "stopped", +}; + +// Responsible for the background_page section of the manifest. +class BackgroundPage extends HiddenExtensionPage { + constructor(extension, options) { + super(extension, "background"); + + this.page = options.page || null; + this.isGenerated = !!options.scripts; + + // Last background/event page created time (retrieved using + // Services.telemetry.msSinceProcessStartExcludingSuspend when the + // parent process proxy context has been created). + this.msSinceCreated = null; + + if (this.page) { + this.url = this.extension.baseURI.resolve(this.page); + } else if (this.isGenerated) { + this.url = this.extension.baseURI.resolve( + "_generated_background_page.html" + ); + } + } + + async build() { + const { extension } = this; + ExtensionTelemetry.backgroundPageLoad.stopwatchStart(extension, this); + + let context; + try { + await this.createBrowserElement(); + if (!this.browser) { + throw new Error( + "Extension shut down before the background page was created" + ); + } + extension._backgroundPageFrameLoader = this.browser.frameLoader; + + extensions.emit("extension-browser-inserted", this.browser); + + let contextPromise = promiseBackgroundViewLoaded(this.browser); + this.browser.fixupAndLoadURIString(this.url, { + triggeringPrincipal: extension.principal, + }); + + context = await contextPromise; + // NOTE: context can be null if the load failed. + + this.msSinceCreated = msSinceProcessStartExcludingSuspend(); + + ExtensionTelemetry.backgroundPageLoad.stopwatchFinish(extension, this); + } catch (e) { + // Extension was down before the background page has loaded. + ExtensionTelemetry.backgroundPageLoad.stopwatchCancel(extension, this); + throw e; + } + + return context; + } + + shutdown() { + this.extension._backgroundPageFrameLoader = null; + super.shutdown(); + } +} + +// Responsible for the background.service_worker section of the manifest. +class BackgroundWorker { + constructor(extension, options) { + this.extension = extension; + this.workerScript = options.service_worker; + + if (!this.workerScript) { + throw new Error("Missing mandatory background.service_worker property"); + } + } + + get registrationInfo() { + const { principal } = this.extension; + return serviceWorkerManager.getRegistrationForAddonPrincipal(principal); + } + + getWorkerInfo(descriptorId) { + return this.registrationInfo?.getWorkerByID(descriptorId); + } + + validateWorkerInfoForContext(context) { + const { extension } = this; + if (!this.getWorkerInfo(context.workerDescriptorId)) { + throw new Error( + `ServiceWorkerInfo not found for ${extension.policy.debugName} contextId ${context.contextId}` + ); + } + } + + async build() { + const { extension } = this; + let context; + const contextPromise = new Promise(resolve => { + // TODO bug 1844486: resolve and/or unwatch when startup is interrupted. + let unwatch = watchExtensionWorkerContextLoaded( + { extension, viewType: "background_worker" }, + context => { + unwatch(); + this.validateWorkerInfoForContext(context); + resolve(context); + } + ); + }); + + // TODO(Bug 17228327): follow up to spawn the active worker for a previously installed + // background service worker. + await serviceWorkerManager.registerForAddonPrincipal( + this.extension.principal + ); + + // TODO bug 1844486: Confirm that a shutdown() call during the above or + // below `await` calls can interrupt build() without leaving a stray worker + // registration behind. + + context = await contextPromise; + + await this.waitForActiveWorker(); + return context; + } + + shutdown(isAppShutdown) { + // All service worker registrations related to the extensions will be unregistered + // - when the extension is shutting down if the application is not also shutting down + // shutdown (in which case a previously registered service worker is expected to stay + // active across browser restarts). + // - when the extension has been uninstalled + if (!isAppShutdown) { + this.registrationInfo?.forceShutdown(); + } + } + + waitForActiveWorker() { + const { extension, registrationInfo } = this; + return new Promise((resolve, reject) => { + const resolveOnActive = () => { + if ( + registrationInfo.activeWorker?.state === + Ci.nsIServiceWorkerInfo.STATE_ACTIVATED + ) { + resolve(); + return true; + } + return false; + }; + + const rejectOnUnregistered = () => { + if (registrationInfo.unregistered) { + reject( + new Error( + `Background service worker unregistered for "${extension.policy.debugName}"` + ) + ); + return true; + } + return false; + }; + + if (resolveOnActive() || rejectOnUnregistered()) { + return; + } + + const listener = { + onChange() { + if (resolveOnActive() || rejectOnUnregistered()) { + registrationInfo.removeListener(listener); + } + }, + }; + registrationInfo.addListener(listener); + }); + } +} + +/** + * The BackgroundContextOwner is instantiated at most once per extension and + * tracks the state of the background context. State changes can be triggered + * by explicit calls to methods with the "setBgState" prefix, but also by the + * background context itself, e.g. via an extension process crash. + * + * This class identifies the following stages of interest: + * + * 1. Initially no active background, waiting for a signal to get started. + * - method: none (at constructor and after setBgStateStopped) + * - state: STOPPED + * - context: null + * 2. Parent-triggered background startup + * - method: setBgStateStarting + * - state: STARTING (was STOPPED) + * - context: null + * 3. Background context creation observed in parent + * - method: none (observed by ExtensionParent's recvCreateProxyContext) + * TODO: add method to observe and keep track of it sooner than stage 4. + * - state: STARTING + * - context: ProxyContextParent subclass (was null) + * 4. Parent-observed background startup completion + * - method: setBgStateRunning + * - state: RUNNING (was STARTING) + * - context: ProxyContextParent (was null) + * 5. Background context unloaded for any reason + * - method: setBgStateStopped + * TODO bug 1844217: This is only implemented for process crashes and + * intentionally triggered terminations, not navigations/reloads. + * When unloads happen due to navigations/reloads, context will be + * null but the state will still be RUNNING. + * - state: STOPPED (was STOPPED, STARTING, RUNNING or SUSPENDING) + * - context: null (was ProxyContextParent if stage 4 ran). + * - Continue at stage 1 if the extension has not shut down yet. + */ +class BackgroundContextOwner { + /** + * @property {BackgroundBuilder} backgroundBuilder + * + * The source of parent-triggered background state changes. + */ + backgroundBuilder; + + /** + * @property {Extension} [extension] + * + * The Extension associated with the background. This is always set and + * cleared at extension shutdown. + */ + extension; + + /** + * @property {BackgroundPage|BackgroundWorker} [bgInstance] + * + * The BackgroundClass instance responsible for creating the background + * context. This is set as soon as there is a desire to start a background, + * and cleared as soon as the background context is not wanted any more. + * + * This field is set iff extension.backgroundState is not STOPPED. + */ + bgInstance = null; + + /** + * @property {ExtensionPageContextParent|BackgroundWorkerContextParent} [context] + * + * The parent-side counterpart to a background context in a child. The value + * is a subclass of ProxyContextParent, which manages its own lifetime. The + * class is ultimately instantiated through bgInstance. It can be destroyed by + * bgInstance or externally (e.g. by the context itself or a process crash). + * The reference to the context is cleared as soon as the context is unloaded. + * + * This is currently set when the background has fully loaded. To access the + * background context before that, use |extension.backgroundContext|. + * + * This field is set when extension.backgroundState is RUNNING or SUSPENDING. + */ + context = null; + + /** + * @property {boolean} [canBePrimed] + * + * This property reflects whether persistent listeners can be primed. This + * means that `backgroundState` is `STOPPED` and the listeners haven't been + * primed yet. It is initially `true`, and set to `false` as soon as + * listeners are primed. It can become `true` again if `primeBackground` was + * skipped due to `shouldPrimeBackground` being `false`. + * NOTE: this flag is set for both event pages and persistent background pages. + */ + canBePrimed = true; + + /** + * @property {boolean} [shouldPrimeBackground] + * + * This property controls whether we should prime listeners. Under normal + * conditions, this should always be `true` but when too many crashes have + * occurred, we might have to disable process spawning, which would lead to + * this property being set to `false`. + */ + shouldPrimeBackground = true; + + get #hasEnteredShutdown() { + // This getter is just a small helper to make sure we always check for + // the extension shutdown being already initiated. + // Ordinarily the extension object is expected to be nullified from the + // onShutdown method, but extension.hasShutdown is set earlier and because + // the shutdown goes through some async steps there is a chance for other + // internals to be hit while the hasShutdown flag is set bug onShutdown + // not hit yet. + return this.extension.hasShutdown || Services.startup.shuttingDown; + } + + /** + * @param {BackgroundBuilder} backgroundBuilder + * @param {Extension} extension + */ + constructor(backgroundBuilder, extension) { + this.backgroundBuilder = backgroundBuilder; + this.extension = extension; + this.onExtensionProcessCrashed = this.onExtensionProcessCrashed.bind(this); + this.onApplicationInForeground = this.onApplicationInForeground.bind(this); + this.onExtensionEnableProcessSpawning = + this.onExtensionEnableProcessSpawning.bind(this); + + extension.backgroundState = BACKGROUND_STATE.STOPPED; + + extensions.on("extension-process-crash", this.onExtensionProcessCrashed); + extensions.on( + "extension-enable-process-spawning", + this.onExtensionEnableProcessSpawning + ); + // We only defer handling extension process crashes for persistent + // background context. + if (extension.persistentBackground) { + extensions.on("application-foreground", this.onApplicationInForeground); + } + } + + /** + * setBgStateStarting - right before the background context is initialized. + * + * @param {BackgroundWorker|BackgroundPage} bgInstance + */ + setBgStateStarting(bgInstance) { + if (!this.extension) { + throw new Error(`Cannot start background after extension shutdown.`); + } + if (this.bgInstance) { + throw new Error(`Cannot start multiple background instances`); + } + this.extension.backgroundState = BACKGROUND_STATE.STARTING; + this.bgInstance = bgInstance; + // Often already false, except if we're waking due to a listener that was + // registered with isInStartup=true. + this.canBePrimed = false; + } + + /** + * setBgStateRunning - when the background context has fully loaded. + * + * This method may throw if the background should no longer be active; if that + * is the case, the caller should make sure that the background is cleaned up + * by calling setBgStateStopped. + * + * @param {ExtensionPageContextParent|BackgroundWorkerContextParent} context + */ + setBgStateRunning(context) { + if (!this.extension) { + // Caller should have checked this. + throw new Error(`Extension has shut down before startup completion.`); + } + if (this.context) { + // This can currently not happen - we set the context only once. + // TODO bug 1844217: Handle navigation (bug 1286083). For now, reject. + throw new Error(`Context already set before at startup completion.`); + } + if (!context) { + throw new Error(`Context not found at startup completion.`); + } + if (context.unloaded) { + throw new Error(`Context has unloaded before startup completion.`); + } + this.extension.backgroundState = BACKGROUND_STATE.RUNNING; + this.context = context; + context.callOnClose(this); + + // When the background startup completes successfully, update the set of + // events that should be persisted. + EventManager.clearPrimedListeners(this.extension, true); + + // This notification will be balanced in setBgStateStopped / close. + notifyBackgroundScriptStatus(this.extension.id, true); + + this.extension.emit("background-script-started"); + } + + /** + * setBgStateStopped - when the background context has unloaded or should be + * unloaded. Regardless of the actual state at the entry of this method, upon + * returning the background is considered stopped. + * + * If the context was active at the time of the invocation, the actual unload + * of |this.context| is asynchronous as it may involve a round-trip to the + * child process. + * + * @param {boolean} [isAppShutdown] + */ + setBgStateStopped(isAppShutdown) { + const backgroundState = this.extension.backgroundState; + if (this.context) { + this.context.forgetOnClose(this); + this.context = null; + // This is the counterpart to the notification in setBgStateRunning. + notifyBackgroundScriptStatus(this.extension.id, false); + } + + // We only need to call clearPrimedListeners for states STOPPED and STARTING + // because setBgStateRunning clears all primed listeners when it switches + // from STARTING to RUNNING. Further, the only way to get primed listeners + // is by a primeListeners call, which only happens in the STOPPED state. + if ( + backgroundState === BACKGROUND_STATE.STOPPED || + backgroundState === BACKGROUND_STATE.STARTING + ) { + EventManager.clearPrimedListeners(this.extension, false); + } + + // Ensure there is no backgroundTimer running + this.backgroundBuilder.clearIdleTimer(); + + const bgInstance = this.bgInstance; + if (bgInstance) { + this.bgInstance = null; + isAppShutdown ||= Services.startup.shuttingDown; + // bgInstance.shutdown() unloads the associated context, if any. + bgInstance.shutdown(isAppShutdown); + this.backgroundBuilder.onBgInstanceShutdown(bgInstance); + } + + this.extension.backgroundState = BACKGROUND_STATE.STOPPED; + if (backgroundState === BACKGROUND_STATE.STARTING) { + this.extension.emit("background-script-aborted"); + } + + if (this.extension.hasShutdown) { + this.extension = null; + } else if (this.shouldPrimeBackground) { + // Prime again, so that a stopped background can always be revived when + // needed. + this.backgroundBuilder.primeBackground(false); + } else { + this.canBePrimed = true; + } + } + + // Called by registration via context.callOnClose (if this.context is set). + close() { + // close() is called when: + // - background context unloads (without replacement context). + // - extension process crashes (without replacement context). + // - background context reloads (context likely replaced by new context). + // - background context navigates (context likely replaced by new context). + // + // When the background is gone without replacement, switch to STOPPED. + // TODO bug 1286083: Drop support for navigations. + + // To fully maintain the state, we should call this.setBgStateStopped(); + // But we cannot do that yet because that would close background pages upon + // reload and navigation, which would be a backwards-incompatible change. + // For now, we only do the bare minimum here. + // + // Note that once a navigation or reload starts, that the context is + // untracked. This is a pre-existing issue that we should fix later. + // TODO bug 1844217: Detect context replacement and update this.context. + if (this.context) { + this.context.forgetOnClose(this); + this.context = null; + // This is the counterpart to the notification in setBgStateRunning. + notifyBackgroundScriptStatus(this.extension.id, false); + } + } + + restartPersistentBackgroundAfterCrash() { + const { extension } = this; + if ( + this.#hasEnteredShutdown || + // Ignore if the background state isn't the one expected to be set + // after a crash. + extension.backgroundState !== BACKGROUND_STATE.STOPPED || + // Auto-restart persistent background scripts after crash disabled by prefs. + disableRestartPersistentAfterCrash + ) { + return; + } + + // Persistent background pages are re-primed from setBgStateStopped when we + // are hitting a crash (if the threshold was not exceeded, otherwise they + // are going to be re-primed from onExtensionEnableProcessSpawning). + extension.emit("start-background-script"); + } + + onExtensionEnableProcessSpawning() { + if (this.#hasEnteredShutdown) { + return; + } + + if (!this.canBePrimed) { + return; + } + + // Allow priming again. + this.shouldPrimeBackground = true; + this.backgroundBuilder.primeBackground(false); + + if (this.extension.persistentBackground) { + this.restartPersistentBackgroundAfterCrash(); + } + } + + onApplicationInForeground(eventName, data) { + if ( + this.#hasEnteredShutdown || + // Past the silent crash handling threashold. + data.processSpawningDisabled + ) { + return; + } + + this.restartPersistentBackgroundAfterCrash(); + } + + onExtensionProcessCrashed(eventName, data) { + if (this.#hasEnteredShutdown) { + return; + } + + // data.childID holds the process ID of the crashed extension process. + // For now, assume that there is only one, so clean up unconditionally. + + this.shouldPrimeBackground = !data.processSpawningDisabled; + + // We only need to clean up if a bgInstance has been created. Without it, + // there is only state in the parent process, not the child, and a crashed + // extension process doesn't affect us. + if (this.bgInstance) { + this.setBgStateStopped(); + } + + if (this.extension.persistentBackground) { + // Defer to when back in foreground and/or process spawning is explicitly re-enabled. + if (!data.appInForeground || data.processSpawningDisabled) { + return; + } + + this.restartPersistentBackgroundAfterCrash(); + } + } + + // Called by ExtensionAPI.onShutdown (once). + onShutdown(isAppShutdown) { + // If a background context was active during extension shutdown, then + // close() was called before onShutdown, which clears |this.extension|. + // If the background has not fully started yet, then we have to clear here. + if (this.extension) { + this.setBgStateStopped(isAppShutdown); + } + extensions.off("extension-process-crash", this.onExtensionProcessCrashed); + extensions.off( + "extension-enable-process-spawning", + this.onExtensionEnableProcessSpawning + ); + extensions.off("application-foreground", this.onApplicationInForeground); + } +} + +/** + * BackgroundBuilder manages the creation and parent-triggered termination of + * the background context. Non-parent-triggered terminations are usually due to + * an external cause (e.g. crashes) and detected by BackgroundContextOwner. + * + * Because these external terminations can happen at any time, and the creation + * and suspension of the background context is async, the methods of this + * BackgroundBuilder class necessarily need to check the state of the background + * before proceeding with the operation (and abort + clean up as needed). + * + * The following interruptions are explicitly accounted for: + * - Extension shuts down. + * - Background unloads for any reason. + * - Another background instance starts in the meantime. + */ +class BackgroundBuilder { + constructor(extension) { + this.extension = extension; + this.backgroundContextOwner = new BackgroundContextOwner(this, extension); + } + + async build() { + if (this.backgroundContextOwner.bgInstance) { + return; + } + + let { extension } = this; + let { manifest } = extension; + extension.backgroundState = BACKGROUND_STATE.STARTING; + + this.isWorker = + !!manifest.background.service_worker && + WebExtensionPolicy.backgroundServiceWorkerEnabled; + + let BackgroundClass = this.isWorker ? BackgroundWorker : BackgroundPage; + + const bgInstance = new BackgroundClass(extension, manifest.background); + this.backgroundContextOwner.setBgStateStarting(bgInstance); + let context; + try { + context = await bgInstance.build(); + } catch (e) { + Cu.reportError(e); + // If background startup gets interrupted (e.g. extension shutdown), + // bgInstance.shutdown() is called and backgroundContextOwner.bgInstance + // is cleared. + if (this.backgroundContextOwner.bgInstance === bgInstance) { + this.backgroundContextOwner.setBgStateStopped(); + } + return; + } + + if (context) { + // Wait until all event listeners registered by the script so far + // to be handled. We then set listenerPromises to null, which indicates + // to addListener that the background script has finished loading. + await Promise.all(context.listenerPromises); + context.listenerPromises = null; + } + + if (this.backgroundContextOwner.bgInstance !== bgInstance) { + // Background closed/restarted in the meantime. + return; + } + + try { + this.backgroundContextOwner.setBgStateRunning(context); + } catch (e) { + Cu.reportError(e); + this.backgroundContextOwner.setBgStateStopped(); + } + } + + observe(subject, topic, data) { + if (topic == "timer-callback") { + let { extension } = this; + this.clearIdleTimer(); + extension?.terminateBackground(); + } + } + + clearIdleTimer() { + this.backgroundTimer?.cancel(); + this.backgroundTimer = null; + } + + resetIdleTimer() { + this.clearIdleTimer(); + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init(this, backgroundIdleTimeout, Ci.nsITimer.TYPE_ONE_SHOT); + this.backgroundTimer = timer; + } + + primeBackground(isInStartup = true) { + let { extension } = this; + + if (this.backgroundContextOwner.bgInstance) { + // This should never happen. The need to prime listeners is mutually + // exclusive with the existence of a background instance. + throw new Error(`bgInstance exists before priming ${extension.id}`); + } + + // Used by runtime messaging to wait for background page listeners. + let bgStartupPromise = new Promise(resolve => { + let done = () => { + extension.off("background-script-started", done); + extension.off("background-script-aborted", done); + extension.off("shutdown", done); + resolve(); + }; + extension.on("background-script-started", done); + extension.on("background-script-aborted", done); + extension.on("shutdown", done); + }); + + extension.promiseBackgroundStarted = () => { + return bgStartupPromise; + }; + + extension.wakeupBackground = () => { + if (extension.hasShutdown) { + return Promise.reject( + new Error( + "wakeupBackground called while the extension was already shutting down" + ) + ); + } + extension.emit("background-script-event"); + // `extension.wakeupBackground` is set back to the original arrow function + // when the background page is terminated and `primeBackground` is called again. + extension.wakeupBackground = () => bgStartupPromise; + return bgStartupPromise; + }; + + let resetBackgroundIdle = (eventName, resetIdleDetails) => { + this.clearIdleTimer(); + if (!this.extension || extension.persistentBackground) { + // Extension was already shut down or is persistent and + // does not idle timout. + return; + } + // TODO remove at an appropriate point in the future prior + // to general availability. There may be some racy conditions + // with idle timeout between an event starting and the event firing + // but we still want testing with an idle timeout. + if ( + !Services.prefs.getBoolPref("extensions.background.idle.enabled", true) + ) { + return; + } + + if ( + extension.backgroundState == BACKGROUND_STATE.SUSPENDING && + // After we begin suspending the background, parent API calls from + // runtime.onSuspend listeners shouldn't cancel the suspension. + resetIdleDetails?.reason !== "parentApiCall" + ) { + extension.backgroundState = BACKGROUND_STATE.RUNNING; + // call runtime.onSuspendCanceled + extension.emit("background-script-suspend-canceled"); + } + + this.resetIdleTimer(); + + if ( + eventName === "background-script-reset-idle" && + // TODO(Bug 1790087): record similar telemetry for background service worker. + !this.isWorker + ) { + // Record the reason for resetting the event page idle timeout + // in a idle result histogram, with the category set based + // on the reason for resetting (defaults to 'reset_other' + // if resetIdleDetails.reason is missing or not mapped into the + // telemetry histogram categories). + // + // Keep this in sync with the categories listed in Histograms.json + // for "WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT". + let category = "reset_other"; + switch (resetIdleDetails?.reason) { + case "event": + category = "reset_event"; + return; // not break; because too frequent, see bug 1868960. + case "hasActiveNativeAppPorts": + category = "reset_nativeapp"; + break; + case "hasActiveStreamFilter": + category = "reset_streamfilter"; + break; + case "pendingListeners": + category = "reset_listeners"; + break; + case "parentApiCall": + category = "reset_parentapicall"; + return; // not break; because too frequent, see bug 1868960. + } + + ExtensionTelemetry.eventPageIdleResult.histogramAdd({ + extension, + category, + }); + } + }; + + // Listen for events from the EventManager + extension.on("background-script-reset-idle", resetBackgroundIdle); + // After the background is started, initiate the first timer + extension.once("background-script-started", resetBackgroundIdle); + + // TODO bug 1844488: terminateBackground should account for externally + // triggered background restarts. It does currently performs various + // backgroundState checks, but it is possible for the background to have + // been crashes or restarted in the meantime. + extension.terminateBackground = async ({ + ignoreDevToolsAttached = false, + disableResetIdleForTest = false, // Disable all reset idle checks for testing purpose. + } = {}) => { + await bgStartupPromise; + if (!this.extension || this.extension.hasShutdown) { + // Extension was already shut down. + return; + } + if (extension.backgroundState != BACKGROUND_STATE.RUNNING) { + return; + } + + if ( + !ignoreDevToolsAttached && + ExtensionParent.DebugUtils.hasDevToolsAttached(extension.id) + ) { + extension.emit("background-script-suspend-ignored"); + return; + } + + // Similar to what happens in recent Chrome version for MV3 extensions, extensions non-persistent + // background scripts with a nativeMessaging port still open or a sendNativeMessage request still + // pending an answer are exempt from being terminated when the idle timeout expires. + // The motivation, as for the similar change that Chrome applies to MV3 extensions, is that using + // the native messaging API have already an higher barrier due to having to specify a native messaging + // host app in their manifest and the user also have to install the native app separately as a native + // application). + if ( + !disableResetIdleForTest && + extension.backgroundContext?.hasActiveNativeAppPorts + ) { + extension.emit("background-script-reset-idle", { + reason: "hasActiveNativeAppPorts", + }); + return; + } + + if ( + !disableResetIdleForTest && + extension.backgroundContext?.pendingRunListenerPromisesCount + ) { + extension.emit("background-script-reset-idle", { + reason: "pendingListeners", + pendingListeners: + extension.backgroundContext.pendingRunListenerPromisesCount, + }); + // Clear the pending promises being tracked when we have reset the idle + // once because some where still pending, so that the pending listeners + // calls can reset the idle timer only once. + extension.backgroundContext.clearPendingRunListenerPromises(); + return; + } + + const childId = extension.backgroundContext?.childId; + if ( + childId !== undefined && + extension.hasPermission("webRequestBlocking") && + (extension.manifestVersion <= 3 || + extension.hasPermission("webRequestFilterResponse")) + ) { + // Ask to the background page context in the child process to check if there are + // StreamFilter instances active (e.g. ones with status "transferringdata" or "suspended", + // see StreamFilterStatus enum defined in StreamFilter.webidl). + // TODO(Bug 1748533): consider additional changes to prevent a StreamFilter that never gets to an + // inactive state from preventing an even page from being ever suspended. + const hasActiveStreamFilter = + await ExtensionParent.ParentAPIManager.queryStreamFilterSuspendCancel( + extension.backgroundContext.childId + ).catch(err => { + // an AbortError raised from the JSWindowActor is expected if the background page was already been + // terminated in the meantime, and so we only log the errors that don't match these particular conditions. + if ( + extension.backgroundState == BACKGROUND_STATE.STOPPED && + DOMException.isInstance(err) && + err.name === "AbortError" + ) { + return false; + } + Cu.reportError(err); + return false; + }); + if (!disableResetIdleForTest && hasActiveStreamFilter) { + extension.emit("background-script-reset-idle", { + reason: "hasActiveStreamFilter", + }); + return; + } + + // Return earlier if extension have started or completed its shutdown in the meantime. + if ( + extension.backgroundState !== BACKGROUND_STATE.RUNNING || + extension.hasShutdown + ) { + return; + } + } + + extension.backgroundState = BACKGROUND_STATE.SUSPENDING; + this.clearIdleTimer(); + // call runtime.onSuspend + await extension.emit("background-script-suspend"); + // If in the meantime another event fired, state will be RUNNING, + // and if it was shutdown it will be STOPPED. + if (extension.backgroundState != BACKGROUND_STATE.SUSPENDING) { + return; + } + extension.off("background-script-reset-idle", resetBackgroundIdle); + + // TODO(Bug 1790087): record similar telemetry for background service worker. + if (!this.isWorker) { + ExtensionTelemetry.eventPageIdleResult.histogramAdd({ + extension, + category: "suspend", + }); + } + + this.backgroundContextOwner.setBgStateStopped(false); + }; + + EventManager.primeListeners(extension, isInStartup); + // Avoid setting the flag to false when called during extension startup. + if (!isInStartup) { + this.backgroundContextOwner.canBePrimed = false; + } + + // TODO: start-background-script and background-script-event should be + // unregistered when build() starts or when the extension shuts down. + extension.once("start-background-script", async () => { + if (!this.extension || this.extension.hasShutdown) { + // Extension was shut down. Don't build the background page. + // Primed listeners have been cleared in onShutdown. + return; + } + await this.build(); + }); + + // There are two ways to start the background page: + // 1. If a primed event fires, then start the background page as + // soon as we have painted a browser window. + // 2. After all windows have been restored on startup (see onManifestEntry). + extension.once("background-script-event", async () => { + await ExtensionParent.browserPaintedPromise; + extension.emit("start-background-script"); + }); + } + + onBgInstanceShutdown(bgInstance) { + const { msSinceCreated } = bgInstance; + const { extension } = this; + + // Emit an event for tests. + extension.emit("shutdown-background-script"); + + if (msSinceCreated) { + const now = msSinceProcessStartExcludingSuspend(); + if ( + now && + // TODO(Bug 1790087): record similar telemetry for background service worker. + !(this.isWorker || extension.persistentBackground) + ) { + ExtensionTelemetry.eventPageRunningTime.histogramAdd({ + extension, + value: now - msSinceCreated, + }); + } + } + } +} + +this.backgroundPage = class extends ExtensionAPI { + async onManifestEntry(entryName) { + let { extension } = this; + + // When in PPB background pages all run in a private context. This check + // simply avoids an extraneous error in the console since the BaseContext + // will throw. + if ( + PrivateBrowsingUtils.permanentPrivateBrowsing && + !extension.privateBrowsingAllowed + ) { + return; + } + + this.backgroundBuilder = new BackgroundBuilder(extension); + + // runtime.onStartup event support. We listen for the first + // background startup then emit a first-run event. + extension.once("background-script-started", () => { + extension.emit("background-first-run"); + }); + + this.backgroundBuilder.primeBackground(); + + // Persistent backgrounds are started immediately except during APP_STARTUP. + // Non-persistent backgrounds must be started immediately for new install or enable + // to initialize the addon and create the persisted listeners. + // updateReason is set when an extension is updated during APP_STARTUP. + if ( + extension.testNoDelayedStartup || + extension.startupReason !== "APP_STARTUP" || + extension.updateReason + ) { + // TODO bug 1543354: Avoid AsyncShutdown timeouts by removing the await + // here, at least for non-test situations. + await this.backgroundBuilder.build(); + + // The task in ExtensionParent.browserPaintedPromise below would be fully + // skipped because of the above build() that sets bgInstance. Return early + // so that it is obvious that the logic is skipped. + return; + } + + ExtensionParent.browserStartupPromise.then(() => { + // Return early if the background has started in the meantime. This can + // happen if a primed listener (isInStartup) has been triggered. + if ( + !this.backgroundBuilder || + this.backgroundBuilder.backgroundContextOwner.bgInstance || + !this.backgroundBuilder.backgroundContextOwner.canBePrimed + ) { + return; + } + + // We either start the background page immediately, or fully prime for + // real. + this.backgroundBuilder.backgroundContextOwner.canBePrimed = false; + + // If there are no listeners for the extension that were persisted, we need to + // start the event page so they can be registered. + if ( + extension.persistentBackground || + !extension.persistentListeners?.size || + // If runtime.onStartup has a listener and this is app_startup, + // start the extension so it will fire the event. + (extension.startupReason == "APP_STARTUP" && + extension.persistentListeners?.get("runtime").has("onStartup")) + ) { + extension.emit("start-background-script"); + } else { + // During startup we only prime startup blocking listeners. At + // this stage we need to prime all listeners for event pages. + EventManager.clearPrimedListeners(extension, false); + // Allow re-priming by deleting existing listeners. + extension.persistentListeners = null; + EventManager.primeListeners(extension, false); + } + }); + } + + onShutdown(isAppShutdown) { + if (this.backgroundBuilder) { + this.backgroundBuilder.backgroundContextOwner.onShutdown(isAppShutdown); + this.backgroundBuilder = null; + } + } +}; diff --git a/toolkit/components/extensions/parent/ext-browserSettings.js b/toolkit/components/extensions/parent/ext-browserSettings.js new file mode 100644 index 0000000000..7b292f76b8 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-browserSettings.js @@ -0,0 +1,592 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs", +}); + +var { ExtensionPreferencesManager } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPreferencesManager.sys.mjs" +); + +var { ExtensionError } = ExtensionUtils; +var { getSettingsAPI, getPrimedSettingsListener } = ExtensionPreferencesManager; + +const HOMEPAGE_URL_PREF = "browser.startup.homepage"; + +const PERM_DENY_ACTION = Services.perms.DENY_ACTION; + +// Add settings objects for supported APIs to the preferences manager. +ExtensionPreferencesManager.addSetting("allowPopupsForUserEvents", { + permission: "browserSettings", + prefNames: ["dom.popup_allowed_events"], + + setCallback(value) { + let returnObj = {}; + // If the value is true, then reset the pref, otherwise set it to "". + returnObj[this.prefNames[0]] = value ? undefined : ""; + return returnObj; + }, + + getCallback() { + return Services.prefs.getCharPref("dom.popup_allowed_events") != ""; + }, +}); + +ExtensionPreferencesManager.addSetting("cacheEnabled", { + permission: "browserSettings", + prefNames: ["browser.cache.disk.enable", "browser.cache.memory.enable"], + + setCallback(value) { + let returnObj = {}; + for (let pref of this.prefNames) { + returnObj[pref] = value; + } + return returnObj; + }, + + getCallback() { + return ( + Services.prefs.getBoolPref("browser.cache.disk.enable") && + Services.prefs.getBoolPref("browser.cache.memory.enable") + ); + }, +}); + +ExtensionPreferencesManager.addSetting("closeTabsByDoubleClick", { + permission: "browserSettings", + prefNames: ["browser.tabs.closeTabByDblclick"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return Services.prefs.getBoolPref("browser.tabs.closeTabByDblclick"); + }, + + validate() { + if (AppConstants.platform == "android") { + throw new ExtensionError( + `android is not a supported platform for the closeTabsByDoubleClick setting.` + ); + } + }, +}); + +ExtensionPreferencesManager.addSetting("colorManagement.mode", { + permission: "browserSettings", + prefNames: ["gfx.color_management.mode"], + + setCallback(value) { + switch (value) { + case "off": + return { [this.prefNames[0]]: 0 }; + case "full": + return { [this.prefNames[0]]: 1 }; + case "tagged_only": + return { [this.prefNames[0]]: 2 }; + } + }, + + getCallback() { + switch (Services.prefs.getIntPref("gfx.color_management.mode")) { + case 0: + return "off"; + case 1: + return "full"; + case 2: + return "tagged_only"; + } + }, +}); + +ExtensionPreferencesManager.addSetting("colorManagement.useNativeSRGB", { + permission: "browserSettings", + prefNames: ["gfx.color_management.native_srgb"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return Services.prefs.getBoolPref("gfx.color_management.native_srgb"); + }, +}); + +ExtensionPreferencesManager.addSetting( + "colorManagement.useWebRenderCompositor", + { + permission: "browserSettings", + prefNames: ["gfx.webrender.compositor"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return Services.prefs.getBoolPref("gfx.webrender.compositor"); + }, + } +); + +ExtensionPreferencesManager.addSetting("contextMenuShowEvent", { + permission: "browserSettings", + prefNames: ["ui.context_menus.after_mouseup"], + + setCallback(value) { + return { [this.prefNames[0]]: value === "mouseup" }; + }, + + getCallback() { + if (AppConstants.platform === "win") { + return "mouseup"; + } + let prefValue = Services.prefs.getBoolPref( + "ui.context_menus.after_mouseup", + null + ); + return prefValue ? "mouseup" : "mousedown"; + }, +}); + +ExtensionPreferencesManager.addSetting("imageAnimationBehavior", { + permission: "browserSettings", + prefNames: ["image.animation_mode"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return Services.prefs.getCharPref("image.animation_mode"); + }, +}); + +ExtensionPreferencesManager.addSetting("newTabPosition", { + permission: "browserSettings", + prefNames: [ + "browser.tabs.insertRelatedAfterCurrent", + "browser.tabs.insertAfterCurrent", + ], + + setCallback(value) { + return { + "browser.tabs.insertAfterCurrent": value === "afterCurrent", + "browser.tabs.insertRelatedAfterCurrent": value === "relatedAfterCurrent", + }; + }, + + getCallback() { + if (Services.prefs.getBoolPref("browser.tabs.insertAfterCurrent")) { + return "afterCurrent"; + } + if (Services.prefs.getBoolPref("browser.tabs.insertRelatedAfterCurrent")) { + return "relatedAfterCurrent"; + } + return "atEnd"; + }, +}); + +ExtensionPreferencesManager.addSetting("openBookmarksInNewTabs", { + permission: "browserSettings", + prefNames: ["browser.tabs.loadBookmarksInTabs"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return Services.prefs.getBoolPref("browser.tabs.loadBookmarksInTabs"); + }, +}); + +ExtensionPreferencesManager.addSetting("openSearchResultsInNewTabs", { + permission: "browserSettings", + prefNames: ["browser.search.openintab"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return Services.prefs.getBoolPref("browser.search.openintab"); + }, +}); + +ExtensionPreferencesManager.addSetting("openUrlbarResultsInNewTabs", { + permission: "browserSettings", + prefNames: ["browser.urlbar.openintab"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return Services.prefs.getBoolPref("browser.urlbar.openintab"); + }, +}); + +ExtensionPreferencesManager.addSetting("webNotificationsDisabled", { + permission: "browserSettings", + prefNames: ["permissions.default.desktop-notification"], + + setCallback(value) { + return { [this.prefNames[0]]: value ? PERM_DENY_ACTION : undefined }; + }, + + getCallback() { + let prefValue = Services.prefs.getIntPref( + "permissions.default.desktop-notification", + null + ); + return prefValue === PERM_DENY_ACTION; + }, +}); + +ExtensionPreferencesManager.addSetting("overrideDocumentColors", { + permission: "browserSettings", + prefNames: ["browser.display.document_color_use"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + let prefValue = Services.prefs.getIntPref( + "browser.display.document_color_use" + ); + if (prefValue === 1) { + return "never"; + } else if (prefValue === 2) { + return "always"; + } + return "high-contrast-only"; + }, +}); + +ExtensionPreferencesManager.addSetting("overrideContentColorScheme", { + permission: "browserSettings", + prefNames: ["layout.css.prefers-color-scheme.content-override"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + let prefValue = Services.prefs.getIntPref( + "layout.css.prefers-color-scheme.content-override" + ); + switch (prefValue) { + case 0: + return "dark"; + case 1: + return "light"; + default: + return "auto"; + } + }, +}); + +ExtensionPreferencesManager.addSetting("useDocumentFonts", { + permission: "browserSettings", + prefNames: ["browser.display.use_document_fonts"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return ( + Services.prefs.getIntPref("browser.display.use_document_fonts") !== 0 + ); + }, +}); + +ExtensionPreferencesManager.addSetting("zoomFullPage", { + permission: "browserSettings", + prefNames: ["browser.zoom.full"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return Services.prefs.getBoolPref("browser.zoom.full"); + }, +}); + +ExtensionPreferencesManager.addSetting("zoomSiteSpecific", { + permission: "browserSettings", + prefNames: ["browser.zoom.siteSpecific"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return Services.prefs.getBoolPref("browser.zoom.siteSpecific"); + }, +}); + +this.browserSettings = class extends ExtensionAPI { + homePageOverrideListener(fire) { + let listener = () => { + fire.async({ + levelOfControl: "not_controllable", + value: Services.prefs.getStringPref(HOMEPAGE_URL_PREF), + }); + }; + Services.prefs.addObserver(HOMEPAGE_URL_PREF, listener); + return { + unregister: () => { + Services.prefs.removeObserver(HOMEPAGE_URL_PREF, listener); + }, + convert(_fire) { + fire = _fire; + }, + }; + } + + newTabOverrideListener(fire) { + let listener = () => { + fire.async({ + levelOfControl: "not_controllable", + value: AboutNewTab.newTabURL, + }); + }; + Services.obs.addObserver(listener, "newtab-url-changed"); + return { + unregister: () => { + Services.obs.removeObserver(listener, "newtab-url-changed"); + }, + convert(_fire) { + fire = _fire; + }, + }; + } + + primeListener(event, fire) { + let { extension } = this; + if (event == "homepageOverride") { + return this.homePageOverrideListener(fire); + } + if (event == "newTabPageOverride") { + return this.newTabOverrideListener(fire); + } + let listener = getPrimedSettingsListener({ + extension, + name: event, + }); + return listener(fire); + } + + getAPI(context) { + let self = this; + let { extension } = context; + + function makeSettingsAPI(name) { + return getSettingsAPI({ + context, + module: "browserSettings", + name, + }); + } + + return { + browserSettings: { + allowPopupsForUserEvents: makeSettingsAPI("allowPopupsForUserEvents"), + cacheEnabled: makeSettingsAPI("cacheEnabled"), + closeTabsByDoubleClick: makeSettingsAPI("closeTabsByDoubleClick"), + contextMenuShowEvent: Object.assign( + makeSettingsAPI("contextMenuShowEvent"), + { + set: details => { + if (!["mouseup", "mousedown"].includes(details.value)) { + throw new ExtensionError( + `${details.value} is not a valid value for contextMenuShowEvent.` + ); + } + if ( + AppConstants.platform === "android" || + (AppConstants.platform === "win" && + details.value === "mousedown") + ) { + return false; + } + return ExtensionPreferencesManager.setSetting( + extension.id, + "contextMenuShowEvent", + details.value + ); + }, + } + ), + ftpProtocolEnabled: getSettingsAPI({ + context, + name: "ftpProtocolEnabled", + readOnly: true, + callback() { + return false; + }, + }), + homepageOverride: getSettingsAPI({ + context, + // Name differs here to preserve this setting properly + name: "homepage_override", + callback() { + return Services.prefs.getStringPref(HOMEPAGE_URL_PREF); + }, + readOnly: true, + onChange: new ExtensionCommon.EventManager({ + context, + module: "browserSettings", + event: "homepageOverride", + name: "homepageOverride.onChange", + register: fire => { + return self.homePageOverrideListener(fire).unregister; + }, + }).api(), + }), + imageAnimationBehavior: makeSettingsAPI("imageAnimationBehavior"), + newTabPosition: makeSettingsAPI("newTabPosition"), + newTabPageOverride: getSettingsAPI({ + context, + // Name differs here to preserve this setting properly + name: "newTabURL", + callback() { + return AboutNewTab.newTabURL; + }, + storeType: "url_overrides", + readOnly: true, + onChange: new ExtensionCommon.EventManager({ + context, + module: "browserSettings", + event: "newTabPageOverride", + name: "newTabPageOverride.onChange", + register: fire => { + return self.newTabOverrideListener(fire).unregister; + }, + }).api(), + }), + openBookmarksInNewTabs: makeSettingsAPI("openBookmarksInNewTabs"), + openSearchResultsInNewTabs: makeSettingsAPI( + "openSearchResultsInNewTabs" + ), + openUrlbarResultsInNewTabs: makeSettingsAPI( + "openUrlbarResultsInNewTabs" + ), + webNotificationsDisabled: makeSettingsAPI("webNotificationsDisabled"), + overrideDocumentColors: Object.assign( + makeSettingsAPI("overrideDocumentColors"), + { + set: details => { + if ( + !["never", "always", "high-contrast-only"].includes( + details.value + ) + ) { + throw new ExtensionError( + `${details.value} is not a valid value for overrideDocumentColors.` + ); + } + let prefValue = 0; // initialize to 0 - auto/high-contrast-only + if (details.value === "never") { + prefValue = 1; + } else if (details.value === "always") { + prefValue = 2; + } + return ExtensionPreferencesManager.setSetting( + extension.id, + "overrideDocumentColors", + prefValue + ); + }, + } + ), + overrideContentColorScheme: Object.assign( + makeSettingsAPI("overrideContentColorScheme"), + { + set: details => { + let value = details.value; + if (value == "system" || value == "browser") { + // Map previous values that used to be different but were + // unified under the "auto" setting. In practice this should + // almost always behave like the extension author expects. + extension.logger.warn( + `The "${value}" value for overrideContentColorScheme has been deprecated. Use "auto" instead` + ); + value = "auto"; + } + let prefValue = ["dark", "light", "auto"].indexOf(value); + if (prefValue === -1) { + throw new ExtensionError( + `${value} is not a valid value for overrideContentColorScheme.` + ); + } + return ExtensionPreferencesManager.setSetting( + extension.id, + "overrideContentColorScheme", + prefValue + ); + }, + } + ), + useDocumentFonts: Object.assign(makeSettingsAPI("useDocumentFonts"), { + set: details => { + if (typeof details.value !== "boolean") { + throw new ExtensionError( + `${details.value} is not a valid value for useDocumentFonts.` + ); + } + return ExtensionPreferencesManager.setSetting( + extension.id, + "useDocumentFonts", + Number(details.value) + ); + }, + }), + zoomFullPage: Object.assign(makeSettingsAPI("zoomFullPage"), { + set: details => { + if (typeof details.value !== "boolean") { + throw new ExtensionError( + `${details.value} is not a valid value for zoomFullPage.` + ); + } + return ExtensionPreferencesManager.setSetting( + extension.id, + "zoomFullPage", + details.value + ); + }, + }), + zoomSiteSpecific: Object.assign(makeSettingsAPI("zoomSiteSpecific"), { + set: details => { + if (typeof details.value !== "boolean") { + throw new ExtensionError( + `${details.value} is not a valid value for zoomSiteSpecific.` + ); + } + return ExtensionPreferencesManager.setSetting( + extension.id, + "zoomSiteSpecific", + details.value + ); + }, + }), + colorManagement: { + mode: makeSettingsAPI("colorManagement.mode"), + useNativeSRGB: makeSettingsAPI("colorManagement.useNativeSRGB"), + useWebRenderCompositor: makeSettingsAPI( + "colorManagement.useWebRenderCompositor" + ), + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-browsingData.js b/toolkit/components/extensions/parent/ext-browsingData.js new file mode 100644 index 0000000000..d06f7a3a1b --- /dev/null +++ b/toolkit/components/extensions/parent/ext-browsingData.js @@ -0,0 +1,405 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + // This helper contains the platform-specific bits of browsingData. + BrowsingDataDelegate: "resource:///modules/ExtensionBrowsingData.sys.mjs", + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + ServiceWorkerCleanUp: "resource://gre/modules/ServiceWorkerCleanUp.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +/** + * A number of iterations after which to yield time back + * to the system. + */ +const YIELD_PERIOD = 10; + +/** + * Convert a Date object to a PRTime (microseconds). + * + * @param {Date} date + * the Date object to convert. + * @returns {integer} microseconds from the epoch. + */ +const toPRTime = date => { + if (typeof date != "number" && date.constructor.name != "Date") { + throw new Error("Invalid value passed to toPRTime"); + } + return date * 1000; +}; + +const makeRange = options => { + return options.since == null + ? null + : [toPRTime(options.since), toPRTime(Date.now())]; +}; +global.makeRange = makeRange; + +// General implementation for clearing data using Services.clearData. +// Currently Sanitizer.items uses this under the hood. +async function clearData(options, flags) { + if (options.hostnames) { + await Promise.all( + options.hostnames.map( + host => + new Promise(resolve => { + // Set aIsUserRequest to true. This means when the ClearDataService + // "Cleaner" implementation doesn't support clearing by host + // it will delete all data instead. + // This is appropriate for cases like |cache|, which doesn't + // support clearing by a time range. + // In future when we use this for other data types, we have to + // evaluate if that behavior is still acceptable. + Services.clearData.deleteDataFromHost(host, true, flags, resolve); + }) + ) + ); + return; + } + + if (options.since) { + const range = makeRange(options); + await new Promise(resolve => { + Services.clearData.deleteDataInTimeRange(...range, true, flags, resolve); + }); + return; + } + + // Don't return the promise here and above to prevent leaking the resolved + // value. + await new Promise(resolve => Services.clearData.deleteData(flags, resolve)); +} + +const clearCache = options => { + return clearData(options, Ci.nsIClearDataService.CLEAR_ALL_CACHES); +}; + +const clearCookies = async function (options) { + let cookieMgr = Services.cookies; + // This code has been borrowed from Sanitizer.jsm. + let yieldCounter = 0; + + if (options.since || options.hostnames || options.cookieStoreId) { + // Iterate through the cookies and delete any created after our cutoff. + let cookies = cookieMgr.cookies; + if ( + !options.cookieStoreId || + isPrivateCookieStoreId(options.cookieStoreId) + ) { + // By default nsICookieManager.cookies doesn't contain private cookies. + const privateCookies = cookieMgr.getCookiesWithOriginAttributes( + JSON.stringify({ + privateBrowsingId: 1, + }) + ); + cookies = cookies.concat(privateCookies); + } + for (const cookie of cookies) { + if ( + (!options.since || cookie.creationTime >= toPRTime(options.since)) && + (!options.hostnames || + options.hostnames.includes(cookie.host.replace(/^\./, ""))) && + (!options.cookieStoreId || + getCookieStoreIdForOriginAttributes(cookie.originAttributes) === + options.cookieStoreId) + ) { + // This cookie was created after our cutoff, clear it. + cookieMgr.remove( + cookie.host, + cookie.name, + cookie.path, + cookie.originAttributes + ); + + if (++yieldCounter % YIELD_PERIOD == 0) { + await new Promise(resolve => setTimeout(resolve, 0)); // Don't block the main thread too long. + } + } + } + } else { + // Remove everything. + cookieMgr.removeAll(); + } +}; + +// Ideally we could reuse the logic in Sanitizer.jsm or nsIClearDataService, +// but this API exposes an ability to wipe data at a much finger granularity +// than those APIs. (See also Bug 1531276) +async function clearQuotaManager(options, dataType) { + // Can not clear localStorage/indexedDB in private browsing mode, + // just ignore. + if (options.cookieStoreId == PRIVATE_STORE) { + return; + } + + let promises = []; + await new Promise((resolve, reject) => { + Services.qms.getUsage(request => { + if (request.resultCode != Cr.NS_OK) { + reject({ message: `Clear ${dataType} failed` }); + return; + } + + for (let item of request.result) { + let principal = + Services.scriptSecurityManager.createContentPrincipalFromOrigin( + item.origin + ); + + // Consistently to removeIndexedDB and the API documentation for + // removeLocalStorage, we should only clear the data stored by + // regular websites, on the contrary we shouldn't clear data stored + // by browser components (like about:newtab) or other extensions. + if (!["http", "https", "file"].includes(principal.scheme)) { + continue; + } + + let host = principal.hostPort; + if ( + (!options.hostnames || options.hostnames.includes(host)) && + (!options.cookieStoreId || + getCookieStoreIdForOriginAttributes(principal.originAttributes) === + options.cookieStoreId) + ) { + promises.push( + new Promise((resolve, reject) => { + let clearRequest; + if (dataType === "indexedDB") { + clearRequest = Services.qms.clearStoragesForPrincipal( + principal, + null, + "idb" + ); + } else { + clearRequest = Services.qms.clearStoragesForPrincipal( + principal, + "default", + "ls" + ); + } + + clearRequest.callback = () => { + if (clearRequest.resultCode == Cr.NS_OK) { + resolve(); + } else { + reject({ message: `Clear ${dataType} failed` }); + } + }; + }) + ); + } + } + + resolve(); + }); + }); + + return Promise.all(promises); +} + +const clearIndexedDB = async function (options) { + return clearQuotaManager(options, "indexedDB"); +}; + +const clearLocalStorage = async function (options) { + if (options.since) { + return Promise.reject({ + message: "Firefox does not support clearing localStorage with 'since'.", + }); + } + + // The legacy LocalStorage implementation that will eventually be removed + // depends on this observer notification. Some other subsystems like + // Reporting headers depend on this too. + // When NextGenLocalStorage is enabled these notifications are ignored. + if (options.hostnames) { + for (let hostname of options.hostnames) { + Services.obs.notifyObservers( + null, + "extension:purge-localStorage", + hostname + ); + } + } else { + Services.obs.notifyObservers(null, "extension:purge-localStorage"); + } + + if (Services.domStorageManager.nextGenLocalStorageEnabled) { + return clearQuotaManager(options, "localStorage"); + } +}; + +const clearPasswords = async function (options) { + let yieldCounter = 0; + + // Iterate through the logins and delete any updated after our cutoff. + for (let login of await LoginHelper.getAllUserFacingLogins()) { + login.QueryInterface(Ci.nsILoginMetaInfo); + if (!options.since || login.timePasswordChanged >= options.since) { + Services.logins.removeLogin(login); + if (++yieldCounter % YIELD_PERIOD == 0) { + await new Promise(resolve => setTimeout(resolve, 0)); // Don't block the main thread too long. + } + } + } +}; + +const clearServiceWorkers = options => { + if (!options.hostnames) { + return ServiceWorkerCleanUp.removeAll(); + } + + return Promise.all( + options.hostnames.map(host => { + return ServiceWorkerCleanUp.removeFromHost(host); + }) + ); +}; + +class BrowsingDataImpl { + constructor(extension) { + this.extension = extension; + // Some APIs cannot implement in a platform-independent way and they are + // delegated to a platform-specific delegate. + this.platformDelegate = new BrowsingDataDelegate(extension); + } + + handleRemoval(dataType, options) { + // First, let's see if the platform implements this + let result = this.platformDelegate.handleRemoval(dataType, options); + if (result !== undefined) { + return result; + } + + // ... if not, run the default behavior. + switch (dataType) { + case "cache": + return clearCache(options); + case "cookies": + return clearCookies(options); + case "indexedDB": + return clearIndexedDB(options); + case "localStorage": + return clearLocalStorage(options); + case "passwords": + return clearPasswords(options); + case "pluginData": + this.extension?.logger.warn( + "pluginData has been deprecated (along with Flash plugin support)" + ); + return Promise.resolve(); + case "serviceWorkers": + return clearServiceWorkers(options); + default: + return undefined; + } + } + + doRemoval(options, dataToRemove) { + if ( + options.originTypes && + (options.originTypes.protectedWeb || options.originTypes.extension) + ) { + return Promise.reject({ + message: + "Firefox does not support protectedWeb or extension as originTypes.", + }); + } + + if (options.cookieStoreId) { + const SUPPORTED_TYPES = ["cookies", "indexedDB"]; + if (Services.domStorageManager.nextGenLocalStorageEnabled) { + // Only the next-gen storage supports removal by cookieStoreId. + SUPPORTED_TYPES.push("localStorage"); + } + + for (let dataType in dataToRemove) { + if (dataToRemove[dataType] && !SUPPORTED_TYPES.includes(dataType)) { + return Promise.reject({ + message: `Firefox does not support clearing ${dataType} with 'cookieStoreId'.`, + }); + } + } + + if ( + !isPrivateCookieStoreId(options.cookieStoreId) && + !isDefaultCookieStoreId(options.cookieStoreId) && + !getContainerForCookieStoreId(options.cookieStoreId) + ) { + return Promise.reject({ + message: `Invalid cookieStoreId: ${options.cookieStoreId}`, + }); + } + } + + let removalPromises = []; + let invalidDataTypes = []; + for (let dataType in dataToRemove) { + if (dataToRemove[dataType]) { + let result = this.handleRemoval(dataType, options); + if (result === undefined) { + invalidDataTypes.push(dataType); + } else { + removalPromises.push(result); + } + } + } + if (invalidDataTypes.length) { + this.extension.logger.warn( + `Firefox does not support dataTypes: ${invalidDataTypes.toString()}.` + ); + } + return Promise.all(removalPromises); + } + + settings() { + return this.platformDelegate.settings(); + } +} + +this.browsingData = class extends ExtensionAPI { + getAPI(context) { + const impl = new BrowsingDataImpl(context.extension); + return { + browsingData: { + settings() { + return impl.settings(); + }, + remove(options, dataToRemove) { + return impl.doRemoval(options, dataToRemove); + }, + removeCache(options) { + return impl.doRemoval(options, { cache: true }); + }, + removeCookies(options) { + return impl.doRemoval(options, { cookies: true }); + }, + removeDownloads(options) { + return impl.doRemoval(options, { downloads: true }); + }, + removeFormData(options) { + return impl.doRemoval(options, { formData: true }); + }, + removeHistory(options) { + return impl.doRemoval(options, { history: true }); + }, + removeIndexedDB(options) { + return impl.doRemoval(options, { indexedDB: true }); + }, + removeLocalStorage(options) { + return impl.doRemoval(options, { localStorage: true }); + }, + removePasswords(options) { + return impl.doRemoval(options, { passwords: true }); + }, + removePluginData(options) { + return impl.doRemoval(options, { pluginData: true }); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-captivePortal.js b/toolkit/components/extensions/parent/ext-captivePortal.js new file mode 100644 index 0000000000..547abaa594 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-captivePortal.js @@ -0,0 +1,158 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "gCPS", + "@mozilla.org/network/captive-portal-service;1", + "nsICaptivePortalService" +); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "gCaptivePortalEnabled", + "network.captive-portal-service.enabled", + false +); + +var { ExtensionPreferencesManager } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPreferencesManager.sys.mjs" +); + +var { getSettingsAPI } = ExtensionPreferencesManager; + +const CAPTIVE_URL_PREF = "captivedetect.canonicalURL"; + +var { ExtensionError } = ExtensionUtils; + +this.captivePortal = class extends ExtensionAPIPersistent { + checkCaptivePortalEnabled() { + if (!gCaptivePortalEnabled) { + throw new ExtensionError("Captive Portal detection is not enabled"); + } + } + + nameForCPSState(state) { + switch (state) { + case gCPS.UNKNOWN: + return "unknown"; + case gCPS.NOT_CAPTIVE: + return "not_captive"; + case gCPS.UNLOCKED_PORTAL: + return "unlocked_portal"; + case gCPS.LOCKED_PORTAL: + return "locked_portal"; + default: + return "unknown"; + } + } + + PERSISTENT_EVENTS = { + onStateChanged({ fire }) { + this.checkCaptivePortalEnabled(); + + let observer = (subject, topic) => { + fire.async({ state: this.nameForCPSState(gCPS.state) }); + }; + + Services.obs.addObserver( + observer, + "ipc:network:captive-portal-set-state" + ); + return { + unregister: () => { + Services.obs.removeObserver( + observer, + "ipc:network:captive-portal-set-state" + ); + }, + convert(_fire, context) { + fire = _fire; + }, + }; + }, + onConnectivityAvailable({ fire }) { + this.checkCaptivePortalEnabled(); + + let observer = (subject, topic, data) => { + fire.async({ status: data }); + }; + + Services.obs.addObserver(observer, "network:captive-portal-connectivity"); + return { + unregister: () => { + Services.obs.removeObserver( + observer, + "network:captive-portal-connectivity" + ); + }, + convert(_fire, context) { + fire = _fire; + }, + }; + }, + "captiveURL.onChange": ({ fire }) => { + let listener = (text, id) => { + fire.async({ + levelOfControl: "not_controllable", + value: Services.prefs.getStringPref(CAPTIVE_URL_PREF), + }); + }; + Services.prefs.addObserver(CAPTIVE_URL_PREF, listener); + return { + unregister: () => { + Services.prefs.removeObserver(CAPTIVE_URL_PREF, listener); + }, + convert(_fire, context) { + fire = _fire; + }, + }; + }, + }; + + getAPI(context) { + let self = this; + return { + captivePortal: { + getState() { + self.checkCaptivePortalEnabled(); + return self.nameForCPSState(gCPS.state); + }, + getLastChecked() { + self.checkCaptivePortalEnabled(); + return gCPS.lastChecked; + }, + onStateChanged: new EventManager({ + context, + module: "captivePortal", + event: "onStateChanged", + extensionApi: self, + }).api(), + onConnectivityAvailable: new EventManager({ + context, + module: "captivePortal", + event: "onConnectivityAvailable", + extensionApi: self, + }).api(), + canonicalURL: getSettingsAPI({ + context, + name: "captiveURL", + callback() { + return Services.prefs.getStringPref(CAPTIVE_URL_PREF); + }, + readOnly: true, + onChange: new ExtensionCommon.EventManager({ + context, + module: "captivePortal", + event: "captiveURL.onChange", + extensionApi: self, + }).api(), + }), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-clipboard.js b/toolkit/components/extensions/parent/ext-clipboard.js new file mode 100644 index 0000000000..9916b14be7 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-clipboard.js @@ -0,0 +1,87 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "imgTools", + "@mozilla.org/image/tools;1", + "imgITools" +); + +const Transferable = Components.Constructor( + "@mozilla.org/widget/transferable;1", + "nsITransferable" +); + +this.clipboard = class extends ExtensionAPI { + getAPI(context) { + return { + clipboard: { + async setImageData(imageData, imageType) { + if (AppConstants.platform == "android") { + return Promise.reject({ + message: + "Writing images to the clipboard is not supported on Android", + }); + } + let img; + try { + img = imgTools.decodeImageFromArrayBuffer( + imageData, + `image/${imageType}` + ); + } catch (e) { + return Promise.reject({ + message: `Data is not a valid ${imageType} image`, + }); + } + + // Other applications can only access the copied image once the data + // is exported via the platform-specific clipboard APIs: + // nsClipboard::SelectionGetEvent (widget/gtk/nsClipboard.cpp) + // nsClipboard::PasteDictFromTransferable (widget/cocoa/nsClipboard.mm) + // nsDataObj::GetDib (widget/windows/nsDataObj.cpp) + // + // The common protocol for exporting a nsITransferable as an image is: + // - Use nsITransferable::GetTransferData to fetch the stored data. + // - QI imgIContainer on the pointer. + // - Convert the image to the native clipboard format. + // + // Below we create a nsITransferable in the above format. + let transferable = new Transferable(); + transferable.init(null); + const kNativeImageMime = "application/x-moz-nativeimage"; + transferable.addDataFlavor(kNativeImageMime); + + // Internal consumers expect the image data to be stored as a + // nsIInputStream. On Linux and Windows, pasted data is directly + // retrieved from the system's native clipboard, and made available + // as a nsIInputStream. + // + // On macOS, nsClipboard::GetNativeClipboardData (nsClipboard.mm) uses + // a cached copy of nsITransferable if available, e.g. when the copy + // was initiated by the same browser instance. To make sure that a + // nsIInputStream is returned instead of the cached imgIContainer, + // the image is exported as as `kNativeImageMime`. Data associated + // with this type is converted to a platform-specific image format + // when written to the clipboard. The type is not used when images + // are read from the clipboard (on all platforms, not just macOS). + // This forces nsClipboard::GetNativeClipboardData to fall back to + // the native clipboard, and return the image as a nsITransferable. + transferable.setTransferData(kNativeImageMime, img); + + Services.clipboard.setData( + transferable, + null, + Services.clipboard.kGlobalClipboard + ); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-contentScripts.js b/toolkit/components/extensions/parent/ext-contentScripts.js new file mode 100644 index 0000000000..068b2c7403 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-contentScripts.js @@ -0,0 +1,232 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +var { ExtensionUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionUtils.sys.mjs" +); + +var { ExtensionError, getUniqueId } = ExtensionUtils; + +function getOriginAttributesPatternForCookieStoreId(cookieStoreId) { + if (isDefaultCookieStoreId(cookieStoreId)) { + return { + userContextId: Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID, + privateBrowsingId: + Ci.nsIScriptSecurityManager.DEFAULT_PRIVATE_BROWSING_ID, + }; + } + if (isPrivateCookieStoreId(cookieStoreId)) { + return { + userContextId: Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID, + privateBrowsingId: 1, + }; + } + if (isContainerCookieStoreId(cookieStoreId)) { + let userContextId = getContainerForCookieStoreId(cookieStoreId); + if (userContextId !== null) { + return { userContextId }; + } + } + + throw new ExtensionError("Invalid cookieStoreId"); +} + +/** + * Represents (in the main browser process) a content script registered + * programmatically (instead of being included in the addon manifest). + * + * @param {ProxyContextParent} context + * The parent proxy context related to the extension context which + * has registered the content script. + * @param {RegisteredContentScriptOptions} details + * The options object related to the registered content script + * (which has the properties described in the content_scripts.json + * JSON API schema file). + */ +class ContentScriptParent { + constructor({ context, details }) { + this.context = context; + this.scriptId = getUniqueId(); + this.blobURLs = new Set(); + + this.options = this._convertOptions(details); + + context.callOnClose(this); + } + + close() { + this.destroy(); + } + + destroy() { + if (this.destroyed) { + throw new Error("Unable to destroy ContentScriptParent twice"); + } + + this.destroyed = true; + + this.context.forgetOnClose(this); + + for (const blobURL of this.blobURLs) { + this.context.cloneScope.URL.revokeObjectURL(blobURL); + } + + this.blobURLs.clear(); + + this.context = null; + this.options = null; + } + + _convertOptions(details) { + const { context } = this; + + const options = { + matches: details.matches, + excludeMatches: details.excludeMatches, + includeGlobs: details.includeGlobs, + excludeGlobs: details.excludeGlobs, + allFrames: details.allFrames, + matchAboutBlank: details.matchAboutBlank, + runAt: details.runAt || "document_idle", + jsPaths: [], + cssPaths: [], + originAttributesPatterns: null, + }; + + if (details.cookieStoreId != null) { + const cookieStoreIds = Array.isArray(details.cookieStoreId) + ? details.cookieStoreId + : [details.cookieStoreId]; + options.originAttributesPatterns = cookieStoreIds.map(cookieStoreId => + getOriginAttributesPatternForCookieStoreId(cookieStoreId) + ); + } + + const convertCodeToURL = (data, mime) => { + const blob = new context.cloneScope.Blob(data, { type: mime }); + const blobURL = context.cloneScope.URL.createObjectURL(blob); + + this.blobURLs.add(blobURL); + + return blobURL; + }; + + if (details.js && details.js.length) { + options.jsPaths = details.js.map(data => { + if (data.file) { + return data.file; + } + + return convertCodeToURL([data.code], "text/javascript"); + }); + } + + if (details.css && details.css.length) { + options.cssPaths = details.css.map(data => { + if (data.file) { + return data.file; + } + + return convertCodeToURL([data.code], "text/css"); + }); + } + + return options; + } + + serialize() { + return this.options; + } +} + +this.contentScripts = class extends ExtensionAPI { + getAPI(context) { + const { extension } = context; + + // Map of the content script registered from the extension context. + // + // Map ContentScriptParent> + const parentScriptsMap = new Map(); + + // Unregister all the scriptId related to a context when it is closed. + context.callOnClose({ + close() { + if (parentScriptsMap.size === 0) { + return; + } + + const scriptIds = Array.from(parentScriptsMap.keys()); + + for (let scriptId of scriptIds) { + extension.registeredContentScripts.delete(scriptId); + } + extension.updateContentScripts(); + + extension.broadcast("Extension:UnregisterContentScripts", { + id: extension.id, + scriptIds, + }); + }, + }); + + return { + contentScripts: { + async register(details) { + for (let origin of details.matches) { + if (!extension.allowedOrigins.subsumes(new MatchPattern(origin))) { + throw new ExtensionError( + `Permission denied to register a content script for ${origin}` + ); + } + } + + const contentScript = new ContentScriptParent({ context, details }); + const { scriptId } = contentScript; + + parentScriptsMap.set(scriptId, contentScript); + + const scriptOptions = contentScript.serialize(); + + extension.registeredContentScripts.set(scriptId, scriptOptions); + extension.updateContentScripts(); + + await extension.broadcast("Extension:RegisterContentScripts", { + id: extension.id, + scripts: [{ scriptId, options: scriptOptions }], + }); + + return scriptId; + }, + + // This method is not available to the extension code, the extension code + // doesn't have access to the internally used scriptId, on the contrary + // the extension code will call script.unregister on the script API object + // that is resolved from the register API method returned promise. + async unregister(scriptId) { + const contentScript = parentScriptsMap.get(scriptId); + if (!contentScript) { + Cu.reportError(new Error(`No such content script ID: ${scriptId}`)); + + return; + } + + parentScriptsMap.delete(scriptId); + extension.registeredContentScripts.delete(scriptId); + extension.updateContentScripts(); + + contentScript.destroy(); + + await extension.broadcast("Extension:UnregisterContentScripts", { + id: extension.id, + scriptIds: [scriptId], + }); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-contextualIdentities.js b/toolkit/components/extensions/parent/ext-contextualIdentities.js new file mode 100644 index 0000000000..c7f28d5e90 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-contextualIdentities.js @@ -0,0 +1,362 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + ContextualIdentityService: + "resource://gre/modules/ContextualIdentityService.sys.mjs", +}); +XPCOMUtils.defineLazyPreferenceGetter( + this, + "containersEnabled", + "privacy.userContext.enabled" +); + +var { ExtensionPreferencesManager } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPreferencesManager.sys.mjs" +); + +var { ExtensionError } = ExtensionUtils; + +const CONTAINER_PREF_INSTALL_DEFAULTS = { + "privacy.userContext.extension": undefined, +}; + +const CONTAINERS_ENABLED_SETTING_NAME = "privacy.containers"; + +const CONTAINER_COLORS = new Map([ + ["blue", "#37adff"], + ["turquoise", "#00c79a"], + ["green", "#51cd00"], + ["yellow", "#ffcb00"], + ["orange", "#ff9f00"], + ["red", "#ff613d"], + ["pink", "#ff4bda"], + ["purple", "#af51f5"], + ["toolbar", "#7c7c7d"], +]); + +const CONTAINER_ICONS = new Set([ + "briefcase", + "cart", + "circle", + "dollar", + "fence", + "fingerprint", + "gift", + "vacation", + "food", + "fruit", + "pet", + "tree", + "chill", +]); + +function getContainerIcon(iconName) { + if (!CONTAINER_ICONS.has(iconName)) { + throw new ExtensionError(`Invalid icon ${iconName} for container`); + } + return `resource://usercontext-content/${iconName}.svg`; +} + +function getContainerColor(colorName) { + if (!CONTAINER_COLORS.has(colorName)) { + throw new ExtensionError(`Invalid color name ${colorName} for container`); + } + return CONTAINER_COLORS.get(colorName); +} + +const convertIdentity = identity => { + let result = { + name: ContextualIdentityService.getUserContextLabel(identity.userContextId), + icon: identity.icon, + iconUrl: getContainerIcon(identity.icon), + color: identity.color, + colorCode: getContainerColor(identity.color), + cookieStoreId: getCookieStoreIdForContainer(identity.userContextId), + }; + + return result; +}; + +const checkAPIEnabled = () => { + if (!containersEnabled) { + throw new ExtensionError("Contextual identities are currently disabled"); + } +}; + +const convertIdentityFromObserver = wrappedIdentity => { + let identity = wrappedIdentity.wrappedJSObject; + let iconUrl, colorCode; + try { + iconUrl = getContainerIcon(identity.icon); + colorCode = getContainerColor(identity.color); + } catch (e) { + return null; + } + + let result = { + name: identity.name, + icon: identity.icon, + iconUrl, + color: identity.color, + colorCode, + cookieStoreId: getCookieStoreIdForContainer(identity.userContextId), + }; + + return result; +}; + +ExtensionPreferencesManager.addSetting(CONTAINERS_ENABLED_SETTING_NAME, { + prefNames: Object.keys(CONTAINER_PREF_INSTALL_DEFAULTS), + + setCallback(value) { + if (value !== true) { + return { + ...CONTAINER_PREF_INSTALL_DEFAULTS, + "privacy.userContext.extension": value, + }; + } + return {}; + }, +}); + +this.contextualIdentities = class extends ExtensionAPIPersistent { + eventRegistrar(eventName) { + return ({ fire }) => { + let observer = (subject, topic) => { + let convertedIdentity = convertIdentityFromObserver(subject); + if (convertedIdentity) { + fire.async({ contextualIdentity: convertedIdentity }); + } + }; + + Services.obs.addObserver(observer, eventName); + return { + unregister() { + Services.obs.removeObserver(observer, eventName); + }, + convert(_fire) { + fire = _fire; + }, + }; + }; + } + + PERSISTENT_EVENTS = { + onCreated: this.eventRegistrar("contextual-identity-created"), + onUpdated: this.eventRegistrar("contextual-identity-updated"), + onRemoved: this.eventRegistrar("contextual-identity-deleted"), + }; + + onStartup() { + let { extension } = this; + + if (extension.hasPermission("contextualIdentities")) { + // Turn on contextual identities, and never turn it off. We handle + // this here to ensure prefs are set when an addon is enabled. + Services.prefs.setBoolPref("privacy.userContext.enabled", true); + Services.prefs.setBoolPref("privacy.userContext.ui.enabled", true); + + ExtensionPreferencesManager.setSetting( + extension.id, + CONTAINERS_ENABLED_SETTING_NAME, + extension.id + ); + } + } + + getAPI(context) { + let self = { + contextualIdentities: { + async get(cookieStoreId) { + checkAPIEnabled(); + let containerId = getContainerForCookieStoreId(cookieStoreId); + if (!containerId) { + throw new ExtensionError( + `Invalid contextual identity: ${cookieStoreId}` + ); + } + + let identity = + ContextualIdentityService.getPublicIdentityFromId(containerId); + return convertIdentity(identity); + }, + + async query(details) { + checkAPIEnabled(); + let identities = []; + ContextualIdentityService.getPublicIdentities().forEach(identity => { + if ( + details.name && + ContextualIdentityService.getUserContextLabel( + identity.userContextId + ) != details.name + ) { + return; + } + + identities.push(convertIdentity(identity)); + }); + + return identities; + }, + + async create(details) { + // Lets prevent making containers that are not valid + getContainerIcon(details.icon); + getContainerColor(details.color); + + let identity = ContextualIdentityService.create( + details.name, + details.icon, + details.color + ); + return convertIdentity(identity); + }, + + async update(cookieStoreId, details) { + checkAPIEnabled(); + let containerId = getContainerForCookieStoreId(cookieStoreId); + if (!containerId) { + throw new ExtensionError( + `Invalid contextual identity: ${cookieStoreId}` + ); + } + + let identity = + ContextualIdentityService.getPublicIdentityFromId(containerId); + if (!identity) { + throw new ExtensionError( + `Invalid contextual identity: ${cookieStoreId}` + ); + } + + if (details.name !== null) { + identity.name = details.name; + } + + if (details.color !== null) { + getContainerColor(details.color); + identity.color = details.color; + } + + if (details.icon !== null) { + getContainerIcon(details.icon); + identity.icon = details.icon; + } + + if ( + !ContextualIdentityService.update( + identity.userContextId, + identity.name, + identity.icon, + identity.color + ) + ) { + throw new ExtensionError( + `Contextual identity failed to update: ${cookieStoreId}` + ); + } + + return convertIdentity(identity); + }, + + async move(cookieStoreIds, position) { + checkAPIEnabled(); + if (!Array.isArray(cookieStoreIds)) { + cookieStoreIds = [cookieStoreIds]; + } + + if (!cookieStoreIds.length) { + return; + } + + const totalIds = + ContextualIdentityService.getPublicIdentities().length; + if (position < -1 || position > totalIds - cookieStoreIds.length) { + throw new ExtensionError(`Moving to invalid position ${position}`); + } + + let userContextIds = []; + cookieStoreIds.forEach((cookieStoreId, index) => { + if (cookieStoreIds.indexOf(cookieStoreId) !== index) { + throw new ExtensionError( + `Duplicate contextual identity: ${cookieStoreId}` + ); + } + + let containerId = getContainerForCookieStoreId(cookieStoreId); + if (!containerId) { + throw new ExtensionError( + `Invalid contextual identity: ${cookieStoreId}` + ); + } + + userContextIds.push(containerId); + }); + + if (!ContextualIdentityService.move(userContextIds, position)) { + throw new ExtensionError( + `Contextual identities failed to move: ${cookieStoreIds}` + ); + } + }, + + async remove(cookieStoreId) { + checkAPIEnabled(); + let containerId = getContainerForCookieStoreId(cookieStoreId); + if (!containerId) { + throw new ExtensionError( + `Invalid contextual identity: ${cookieStoreId}` + ); + } + + let identity = + ContextualIdentityService.getPublicIdentityFromId(containerId); + if (!identity) { + throw new ExtensionError( + `Invalid contextual identity: ${cookieStoreId}` + ); + } + + // We have to create the identity object before removing it. + let convertedIdentity = convertIdentity(identity); + + if (!ContextualIdentityService.remove(identity.userContextId)) { + throw new ExtensionError( + `Contextual identity failed to remove: ${cookieStoreId}` + ); + } + + return convertedIdentity; + }, + + onCreated: new EventManager({ + context, + module: "contextualIdentities", + event: "onCreated", + extensionApi: this, + }).api(), + + onUpdated: new EventManager({ + context, + module: "contextualIdentities", + event: "onUpdated", + extensionApi: this, + }).api(), + + onRemoved: new EventManager({ + context, + module: "contextualIdentities", + event: "onRemoved", + extensionApi: this, + }).api(), + }, + }; + + return self; + } +}; diff --git a/toolkit/components/extensions/parent/ext-cookies.js b/toolkit/components/extensions/parent/ext-cookies.js new file mode 100644 index 0000000000..9308a56cfd --- /dev/null +++ b/toolkit/components/extensions/parent/ext-cookies.js @@ -0,0 +1,696 @@ +/* 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"; + +/* globals DEFAULT_STORE, PRIVATE_STORE */ + +var { ExtensionError } = ExtensionUtils; + +const SAME_SITE_STATUSES = [ + "no_restriction", // Index 0 = Ci.nsICookie.SAMESITE_NONE + "lax", // Index 1 = Ci.nsICookie.SAMESITE_LAX + "strict", // Index 2 = Ci.nsICookie.SAMESITE_STRICT +]; + +const isIPv4 = host => { + let match = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(host); + + if (match) { + return match[1] < 256 && match[2] < 256 && match[3] < 256 && match[4] < 256; + } + return false; +}; +const isIPv6 = host => host.includes(":"); +const addBracketIfIPv6 = host => + isIPv6(host) && !host.startsWith("[") ? `[${host}]` : host; +const dropBracketIfIPv6 = host => + isIPv6(host) && host.startsWith("[") && host.endsWith("]") + ? host.slice(1, -1) + : host; + +// Converts the partitionKey format of the extension API (i.e. PartitionKey) to +// a valid format for the "partitionKey" member of OriginAttributes. +function fromExtPartitionKey(extPartitionKey) { + if (!extPartitionKey) { + // Unpartitioned by default. + return ""; + } + const { topLevelSite } = extPartitionKey; + // TODO: Expand API to force the generation of a partitionKey that differs + // from the default that's specified by privacy.dynamic_firstparty.use_site. + if (topLevelSite) { + // If topLevelSite is set and a non-empty string (a site in a URL format). + try { + return ChromeUtils.getPartitionKeyFromURL(topLevelSite); + } catch (e) { + throw new ExtensionError("Invalid value for 'partitionKey' attribute"); + } + } + // Unpartitioned. + return ""; +} +// Converts an internal partitionKey (format used by OriginAttributes) to the +// string value as exposed through the extension API. +function toExtPartitionKey(partitionKey) { + if (!partitionKey) { + // Canonical representation of an empty partitionKey is null. + // In theory {topLevelSite: ""} also works, but alas. + return null; + } + // Parse partitionKey in order to generate the desired return type (URL). + // OriginAttributes::ParsePartitionKey cannot be used because it assumes that + // the input matches the format of the privacy.dynamic_firstparty.use_site + // pref, which is not necessarily the case for cookies before the pref flip. + if (!partitionKey.startsWith("(")) { + // A partitionKey generated with privacy.dynamic_firstparty.use_site=false. + return { topLevelSite: `https://${partitionKey}` }; + } + // partitionKey starts with "(" and ends with ")". + let [scheme, domain, port] = partitionKey.slice(1, -1).split(","); + let topLevelSite = `${scheme}://${domain}`; + if (port) { + topLevelSite += `:${port}`; + } + return { topLevelSite }; +} + +const convertCookie = ({ cookie, isPrivate }) => { + let result = { + name: cookie.name, + value: cookie.value, + domain: addBracketIfIPv6(cookie.host), + hostOnly: !cookie.isDomain, + path: cookie.path, + secure: cookie.isSecure, + httpOnly: cookie.isHttpOnly, + sameSite: SAME_SITE_STATUSES[cookie.sameSite], + session: cookie.isSession, + firstPartyDomain: cookie.originAttributes.firstPartyDomain || "", + partitionKey: toExtPartitionKey(cookie.originAttributes.partitionKey), + }; + + if (!cookie.isSession) { + result.expirationDate = cookie.expiry; + } + + if (cookie.originAttributes.userContextId) { + result.storeId = getCookieStoreIdForContainer( + cookie.originAttributes.userContextId + ); + } else if (cookie.originAttributes.privateBrowsingId || isPrivate) { + result.storeId = PRIVATE_STORE; + } else { + result.storeId = DEFAULT_STORE; + } + + return result; +}; + +const isSubdomain = (otherDomain, baseDomain) => { + return otherDomain == baseDomain || otherDomain.endsWith("." + baseDomain); +}; + +// Checks that the given extension has permission to set the given cookie for +// the given URI. +const checkSetCookiePermissions = (extension, uri, cookie) => { + // Permission checks: + // + // - If the extension does not have permissions for the specified + // URL, it cannot set cookies for it. + // + // - If the specified URL could not set the given cookie, neither can + // the extension. + // + // Ideally, we would just have the cookie service make the latter + // determination, but that turns out to be quite complicated. At the + // moment, it requires constructing a cookie string and creating a + // dummy channel, both of which can be problematic. It also triggers + // a whole set of additional permission and preference checks, which + // may or may not be desirable. + // + // So instead, we do a similar set of checks here. Exactly what + // cookies a given URL should be able to set is not well-documented, + // and is not standardized in any standard that anyone actually + // follows. So instead, we follow the rules used by the cookie + // service. + // + // See source/netwerk/cookie/CookieService.cpp, in particular + // CheckDomain() and SetCookieInternal(). + + if (uri.scheme != "http" && uri.scheme != "https") { + return false; + } + + if (!extension.allowedOrigins.matches(uri)) { + return false; + } + + if (!cookie.host) { + // If no explicit host is specified, this becomes a host-only cookie. + cookie.host = uri.host; + return true; + } + + // A leading "." is not expected, but is tolerated if it's not the only + // character in the host. If there is one, start by stripping it off. We'll + // add a new one on success. + if (cookie.host.length > 1) { + cookie.host = cookie.host.replace(/^\./, ""); + } + cookie.host = cookie.host.toLowerCase(); + cookie.host = dropBracketIfIPv6(cookie.host); + + if (cookie.host != uri.host) { + // Not an exact match, so check for a valid subdomain. + let baseDomain; + try { + baseDomain = Services.eTLD.getBaseDomain(uri); + } catch (e) { + if ( + e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS || + e.result == Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS + ) { + // The cookie service uses these to determine whether the domain + // requires an exact match. We already know we don't have an exact + // match, so return false. In all other cases, re-raise the error. + return false; + } + throw e; + } + + // The cookie domain must be a subdomain of the base domain. This prevents + // us from setting cookies for domains like ".co.uk". + // The domain of the requesting URL must likewise be a subdomain of the + // cookie domain. This prevents us from setting cookies for entirely + // unrelated domains. + if ( + !isSubdomain(cookie.host, baseDomain) || + !isSubdomain(uri.host, cookie.host) + ) { + return false; + } + + // RFC2109 suggests that we may only add cookies for sub-domains 1-level + // below us, but enforcing that would break the web, so we don't. + } + + // If the host is an IP address, avoid adding a leading ".". + // An IP address is not a domain name, and only supports host-only cookies. + if (isIPv6(cookie.host) || isIPv4(cookie.host)) { + return true; + } + + // An explicit domain was passed, so add a leading "." to make this a + // domain cookie. + cookie.host = "." + cookie.host; + + // We don't do any significant checking of path permissions. RFC2109 + // suggests we only allow sites to add cookies for sub-paths, similar to + // same origin policy enforcement, but no-one implements this. + + return true; +}; + +/** + * Converts the details received from the cookies API to the OriginAttributes + * format, using default values when needed (firstPartyDomain/partitionKey). + * + * If allowPattern is true, an OriginAttributesPattern may be returned instead. + * + * @param {object} details + * The details received from the extension. + * @param {BaseContext} context + * @param {boolean} allowPattern + * Whether to potentially return an OriginAttributesPattern instead of + * OriginAttributes. The get/set/remove cookie methods operate on exact + * OriginAttributes, the getAll method allows a partial pattern and may + * potentially match cookies with distinct origin attributes. + * @returns {object} An object with the following properties: + * - originAttributes {OriginAttributes|OriginAttributesPattern} + * - isPattern {boolean} Whether originAttributes is a pattern. + * - isPrivate {boolean} Whether the cookie belongs to private browsing mode. + * - storeId {string} The storeId of the cookie. + */ +const oaFromDetails = (details, context, allowPattern) => { + // Default values, may be filled in based on details. + let originAttributes = { + userContextId: 0, + privateBrowsingId: 0, + // The following two keys may be deleted if allowPattern=true + firstPartyDomain: details.firstPartyDomain ?? "", + partitionKey: fromExtPartitionKey(details.partitionKey), + }; + + let isPrivate = context.incognito; + let storeId = isPrivate ? PRIVATE_STORE : DEFAULT_STORE; + if (details.storeId) { + storeId = details.storeId; + if (isDefaultCookieStoreId(storeId)) { + isPrivate = false; + } else if (isPrivateCookieStoreId(storeId)) { + isPrivate = true; + } else { + isPrivate = false; + let userContextId = getContainerForCookieStoreId(storeId); + if (!userContextId) { + throw new ExtensionError(`Invalid cookie store id: "${storeId}"`); + } + originAttributes.userContextId = userContextId; + } + } + + if (isPrivate) { + originAttributes.privateBrowsingId = 1; + if (!context.privateBrowsingAllowed) { + throw new ExtensionError( + "Extension disallowed access to the private cookies storeId." + ); + } + } + + // If any of the originAttributes's keys are deleted, this becomes true. + let isPattern = false; + if (allowPattern) { + // firstPartyDomain is unset / void / string. + // If unset, then we default to non-FPI cookies (or if FPI is enabled, + // an error is thrown by validateFirstPartyDomain). We are able to detect + // whether the property is set due to "omit-key-if-missing" in cookies.json. + // If set to a string, we keep the filter. + // If set to void (undefined / null), we drop the FPI filter: + if ("firstPartyDomain" in details && details.firstPartyDomain == null) { + delete originAttributes.firstPartyDomain; + isPattern = true; + } + + // partitionKey is an object or null. + // null implies the default (unpartitioned cookies). + // An object is a filter for partitionKey; currently we require topLevelSite + // to be set to determine the exact partitionKey. Without it, we drop the + // dFPI filter: + if (details.partitionKey && details.partitionKey.topLevelSite == null) { + delete originAttributes.partitionKey; + isPattern = true; + } + } + return { originAttributes, isPattern, isPrivate, storeId }; +}; + +/** + * Query the cookie store for matching cookies. + * + * @param {object} detailsIn + * @param {Array} props Properties the extension is interested in matching against. + * The firstPartyDomain / partitionKey / storeId + * props are always accounted for. + * @param {BaseContext} context The context making the query. + * @param {boolean} allowPattern Whether to allow the query to match distinct + * origin attributes instead of falling back to + * default values. See the oaFromDetails method. + */ +const query = function* (detailsIn, props, context, allowPattern) { + let details = {}; + props.forEach(property => { + if (detailsIn[property] !== null) { + details[property] = detailsIn[property]; + } + }); + + let parsedOA; + try { + parsedOA = oaFromDetails(detailsIn, context, allowPattern); + } catch (e) { + if (e.message.startsWith("Invalid cookie store id")) { + // For backwards-compatibility with previous versions of Firefox, fail + // silently (by not returning any results) instead of throwing an error. + return; + } + throw e; + } + let { originAttributes, isPattern, isPrivate, storeId } = parsedOA; + + if ("domain" in details) { + details.domain = details.domain.toLowerCase().replace(/^\./, ""); + details.domain = dropBracketIfIPv6(details.domain); + } + + // We can use getCookiesFromHost for faster searching. + let cookies; + let host; + let url; + if ("url" in details) { + try { + url = new URL(details.url); + host = dropBracketIfIPv6(url.hostname); + } catch (ex) { + // This often happens for about: URLs + return; + } + } else if ("domain" in details) { + host = details.domain; + } + + if (host && !isPattern) { + // getCookiesFromHost is more efficient than getCookiesWithOriginAttributes + // if the host and all origin attributes are known. + cookies = Services.cookies.getCookiesFromHost(host, originAttributes); + } else { + cookies = Services.cookies.getCookiesWithOriginAttributes( + JSON.stringify(originAttributes), + host + ); + } + + // Based on CookieService::GetCookieStringFromHttp + function matches(cookie) { + function domainMatches(host) { + return ( + cookie.rawHost == host || + (cookie.isDomain && host.endsWith(cookie.host)) + ); + } + + function pathMatches(path) { + let cookiePath = cookie.path.replace(/\/$/, ""); + + if (!path.startsWith(cookiePath)) { + return false; + } + + // path == cookiePath, but without the redundant string compare. + if (path.length == cookiePath.length) { + return true; + } + + // URL path is a substring of the cookie path, so it matches if, and + // only if, the next character is a path delimiter. + return path[cookiePath.length] === "/"; + } + + // "Restricts the retrieved cookies to those that would match the given URL." + if (url) { + if (!domainMatches(host)) { + return false; + } + + if (cookie.isSecure && url.protocol != "https:") { + return false; + } + + if (!pathMatches(url.pathname)) { + return false; + } + } + + if ("name" in details && details.name != cookie.name) { + return false; + } + + // "Restricts the retrieved cookies to those whose domains match or are subdomains of this one." + if ("domain" in details && !isSubdomain(cookie.rawHost, details.domain)) { + return false; + } + + // "Restricts the retrieved cookies to those whose path exactly matches this string."" + if ("path" in details && details.path != cookie.path) { + return false; + } + + if ("secure" in details && details.secure != cookie.isSecure) { + return false; + } + + if ("session" in details && details.session != cookie.isSession) { + return false; + } + + // Check that the extension has permissions for this host. + if (!context.extension.allowedOrigins.matchesCookie(cookie)) { + return false; + } + + return true; + } + + for (const cookie of cookies) { + if (matches(cookie)) { + yield { cookie, isPrivate, storeId }; + } + } +}; + +const validateFirstPartyDomain = details => { + if (details.firstPartyDomain != null) { + return; + } + if (Services.prefs.getBoolPref("privacy.firstparty.isolate")) { + throw new ExtensionError( + "First-Party Isolation is enabled, but the required 'firstPartyDomain' attribute was not set." + ); + } +}; + +this.cookies = class extends ExtensionAPIPersistent { + PERSISTENT_EVENTS = { + onChanged({ fire }) { + let observer = (subject, topic) => { + let notify = (removed, cookie, cause) => { + cookie.QueryInterface(Ci.nsICookie); + + if (this.extension.allowedOrigins.matchesCookie(cookie)) { + fire.async({ + removed, + cookie: convertCookie({ + cookie, + isPrivate: topic == "private-cookie-changed", + }), + cause, + }); + } + }; + + let notification = subject.QueryInterface(Ci.nsICookieNotification); + let { cookie } = notification; + + let { + COOKIE_DELETED, + COOKIE_ADDED, + COOKIE_CHANGED, + COOKIES_BATCH_DELETED, + } = Ci.nsICookieNotification; + + // We do our best effort here to map the incompatible states. + switch (notification.action) { + case COOKIE_DELETED: + notify(true, cookie, "explicit"); + break; + case COOKIE_ADDED: + notify(false, cookie, "explicit"); + break; + case COOKIE_CHANGED: + notify(true, cookie, "overwrite"); + notify(false, cookie, "explicit"); + break; + case COOKIES_BATCH_DELETED: + let cookieArray = notification.batchDeletedCookies.QueryInterface( + Ci.nsIArray + ); + for (let i = 0; i < cookieArray.length; i++) { + let cookie = cookieArray.queryElementAt(i, Ci.nsICookie); + if (!cookie.isSession && cookie.expiry * 1000 <= Date.now()) { + notify(true, cookie, "expired"); + } else { + notify(true, cookie, "evicted"); + } + } + break; + } + }; + + const { privateBrowsingAllowed } = this.extension; + Services.obs.addObserver(observer, "cookie-changed"); + if (privateBrowsingAllowed) { + Services.obs.addObserver(observer, "private-cookie-changed"); + } + return { + unregister() { + Services.obs.removeObserver(observer, "cookie-changed"); + if (privateBrowsingAllowed) { + Services.obs.removeObserver(observer, "private-cookie-changed"); + } + }, + convert(_fire) { + fire = _fire; + }, + }; + }, + }; + getAPI(context) { + let { extension } = context; + let self = { + cookies: { + get: function (details) { + validateFirstPartyDomain(details); + + // TODO bug 1818968: We don't sort by length of path and creation time. + let allowed = ["url", "name"]; + for (let cookie of query(details, allowed, context)) { + return Promise.resolve(convertCookie(cookie)); + } + + // Found no match. + return Promise.resolve(null); + }, + + getAll: function (details) { + if (!("firstPartyDomain" in details)) { + // Check and throw an error if firstPartyDomain is required. + validateFirstPartyDomain(details); + } + + let allowed = ["url", "name", "domain", "path", "secure", "session"]; + let result = Array.from( + query(details, allowed, context, /* allowPattern = */ true), + convertCookie + ); + + return Promise.resolve(result); + }, + + set: function (details) { + validateFirstPartyDomain(details); + if (details.firstPartyDomain && details.partitionKey) { + // FPI and dFPI are mutually exclusive, so it does not make sense + // to accept non-empty (i.e. non-default) values for both. + throw new ExtensionError( + "Partitioned cookies cannot have a 'firstPartyDomain' attribute." + ); + } + + let uri = Services.io.newURI(details.url); + + let path; + if (details.path !== null) { + path = details.path; + } else { + // This interface essentially emulates the behavior of the + // Set-Cookie header. In the case of an omitted path, the cookie + // service uses the directory path of the requesting URL, ignoring + // any filename or query parameters. + path = uri.QueryInterface(Ci.nsIURL).directory; + } + + let name = details.name !== null ? details.name : ""; + let value = details.value !== null ? details.value : ""; + let secure = details.secure !== null ? details.secure : false; + let httpOnly = details.httpOnly !== null ? details.httpOnly : false; + let isSession = details.expirationDate === null; + let expiry = isSession + ? Number.MAX_SAFE_INTEGER + : details.expirationDate; + + let { originAttributes } = oaFromDetails(details, context); + + let cookieAttrs = { + host: details.domain, + path: path, + isSecure: secure, + }; + if (!checkSetCookiePermissions(extension, uri, cookieAttrs)) { + return Promise.reject({ + message: `Permission denied to set cookie ${JSON.stringify( + details + )}`, + }); + } + + let sameSite = SAME_SITE_STATUSES.indexOf(details.sameSite); + + let schemeType = Ci.nsICookie.SCHEME_UNSET; + if (uri.scheme === "https") { + schemeType = Ci.nsICookie.SCHEME_HTTPS; + } else if (uri.scheme === "http") { + schemeType = Ci.nsICookie.SCHEME_HTTP; + } else if (uri.scheme === "file") { + schemeType = Ci.nsICookie.SCHEME_FILE; + } + + // The permission check may have modified the domain, so use + // the new value instead. + Services.cookies.add( + cookieAttrs.host, + path, + name, + value, + secure, + httpOnly, + isSession, + expiry, + originAttributes, + sameSite, + schemeType + ); + + return self.cookies.get(details); + }, + + remove: function (details) { + validateFirstPartyDomain(details); + + let allowed = ["url", "name"]; + for (let { cookie, storeId } of query(details, allowed, context)) { + Services.cookies.remove( + cookie.host, + cookie.name, + cookie.path, + cookie.originAttributes + ); + + // TODO Bug 1387957: could there be multiple per subdomain? + return Promise.resolve({ + url: details.url, + name: details.name, + storeId, + firstPartyDomain: cookie.originAttributes.firstPartyDomain, + partitionKey: toExtPartitionKey( + cookie.originAttributes.partitionKey + ), + }); + } + + return Promise.resolve(null); + }, + + getAllCookieStores: function () { + let data = {}; + for (let tab of extension.tabManager.query()) { + if (!(tab.cookieStoreId in data)) { + data[tab.cookieStoreId] = []; + } + data[tab.cookieStoreId].push(tab.id); + } + + let result = []; + for (let key in data) { + result.push({ + id: key, + tabIds: data[key], + incognito: key == PRIVATE_STORE, + }); + } + return Promise.resolve(result); + }, + + onChanged: new EventManager({ + context, + module: "cookies", + event: "onChanged", + extensionApi: this, + }).api(), + }, + }; + + return self; + } +}; diff --git a/toolkit/components/extensions/parent/ext-declarativeNetRequest.js b/toolkit/components/extensions/parent/ext-declarativeNetRequest.js new file mode 100644 index 0000000000..766a43d98a --- /dev/null +++ b/toolkit/components/extensions/parent/ext-declarativeNetRequest.js @@ -0,0 +1,169 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs", +}); + +var { ExtensionError } = ExtensionUtils; + +const PREF_DNR_FEEDBACK = "extensions.dnr.feedback"; +XPCOMUtils.defineLazyPreferenceGetter( + this, + "dnrFeedbackEnabled", + PREF_DNR_FEEDBACK, + false +); + +function ensureDNRFeedbackEnabled(apiName) { + if (!dnrFeedbackEnabled) { + throw new ExtensionError( + `${apiName} is only available when the "${PREF_DNR_FEEDBACK}" preference is set to true.` + ); + } +} + +this.declarativeNetRequest = class extends ExtensionAPI { + onManifestEntry(entryName) { + if (entryName === "declarative_net_request") { + ExtensionDNR.validateManifestEntry(this.extension); + } + } + + onShutdown() { + ExtensionDNR.clearRuleManager(this.extension); + } + + getAPI(context) { + const { extension } = this; + + return { + declarativeNetRequest: { + updateDynamicRules({ removeRuleIds, addRules }) { + return ExtensionDNR.updateDynamicRules(extension, { + removeRuleIds, + addRules, + }); + }, + + updateSessionRules({ removeRuleIds, addRules }) { + const ruleManager = ExtensionDNR.getRuleManager(extension); + let ruleValidator = new ExtensionDNR.RuleValidator( + ruleManager.getSessionRules(), + { isSessionRuleset: true } + ); + if (removeRuleIds) { + ruleValidator.removeRuleIds(removeRuleIds); + } + if (addRules) { + ruleValidator.addRules(addRules); + } + let failures = ruleValidator.getFailures(); + if (failures.length) { + throw new ExtensionError(failures[0].message); + } + let validatedRules = ruleValidator.getValidatedRules(); + let ruleQuotaCounter = new ExtensionDNR.RuleQuotaCounter(); + ruleQuotaCounter.tryAddRules("_session", validatedRules); + ruleManager.setSessionRules(validatedRules); + }, + + async getEnabledRulesets() { + await ExtensionDNR.ensureInitialized(extension); + const ruleManager = ExtensionDNR.getRuleManager(extension); + return ruleManager.enabledStaticRulesetIds; + }, + + async getAvailableStaticRuleCount() { + await ExtensionDNR.ensureInitialized(extension); + const ruleManager = ExtensionDNR.getRuleManager(extension); + return ruleManager.availableStaticRuleCount; + }, + + updateEnabledRulesets({ disableRulesetIds, enableRulesetIds }) { + return ExtensionDNR.updateEnabledStaticRulesets(extension, { + disableRulesetIds, + enableRulesetIds, + }); + }, + + async getDynamicRules() { + await ExtensionDNR.ensureInitialized(extension); + return ExtensionDNR.getRuleManager(extension).getDynamicRules(); + }, + + getSessionRules() { + // ruleManager.getSessionRules() returns an array of Rule instances. + // When these are structurally cloned (to send them to the child), + // the enumerable public fields of the class instances are copied to + // plain objects, as desired. + return ExtensionDNR.getRuleManager(extension).getSessionRules(); + }, + + isRegexSupported(regexOptions) { + const { + regex: regexFilter, + isCaseSensitive: isUrlFilterCaseSensitive, + // requireCapturing: is ignored, as it does not affect validation. + } = regexOptions; + + let ruleValidator = new ExtensionDNR.RuleValidator([]); + ruleValidator.addRules([ + { + id: 1, + condition: { regexFilter, isUrlFilterCaseSensitive }, + action: { type: "allow" }, + }, + ]); + let failures = ruleValidator.getFailures(); + if (failures.length) { + // While the UnsupportedRegexReason enum has more entries than just + // "syntaxError" (e.g. also "memoryLimitExceeded"), our validation + // is currently very permissive, and therefore the only + // distinguishable error is "syntaxError". + return { isSupported: false, reason: "syntaxError" }; + } + return { isSupported: true }; + }, + + async testMatchOutcome(request, options) { + ensureDNRFeedbackEnabled("declarativeNetRequest.testMatchOutcome"); + let { url, initiator, ...req } = request; + req.requestURI = Services.io.newURI(url); + if (initiator) { + req.initiatorURI = Services.io.newURI(initiator); + if (req.initiatorURI.schemeIs("data")) { + // data:-URIs are always opaque, i.e. a null principal. We should + // therefore ignore them here. + // ExtensionDNR's NetworkIntegration.startDNREvaluation does not + // encounter data:-URIs because opaque principals are mapped to a + // null initiatorURI. For consistency, we do the same here. + req.initiatorURI = null; + } + } + const matchedRules = ExtensionDNR.getMatchedRulesForRequest( + req, + options?.includeOtherExtensions ? null : extension + ).map(matchedRule => { + // Converts an internal MatchedRule instance to an object described + // by the "MatchedRule" type in declarative_net_request.json. + const result = { + ruleId: matchedRule.rule.id, + rulesetId: matchedRule.ruleset.id, + }; + if (matchedRule.ruleManager.extension !== extension) { + result.extensionId = matchedRule.ruleManager.extension.id; + } + return result; + }); + return { matchedRules }; + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-dns.js b/toolkit/components/extensions/parent/ext-dns.js new file mode 100644 index 0000000000..f32243c032 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-dns.js @@ -0,0 +1,87 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +const dnssFlags = { + allow_name_collisions: Ci.nsIDNSService.RESOLVE_ALLOW_NAME_COLLISION, + bypass_cache: Ci.nsIDNSService.RESOLVE_BYPASS_CACHE, + canonical_name: Ci.nsIDNSService.RESOLVE_CANONICAL_NAME, + disable_ipv4: Ci.nsIDNSService.RESOLVE_DISABLE_IPV4, + disable_ipv6: Ci.nsIDNSService.RESOLVE_DISABLE_IPV6, + disable_trr: Ci.nsIDNSService.RESOLVE_DISABLE_TRR, + offline: Ci.nsIDNSService.RESOLVE_OFFLINE, + priority_low: Ci.nsIDNSService.RESOLVE_PRIORITY_LOW, + priority_medium: Ci.nsIDNSService.RESOLVE_PRIORITY_MEDIUM, + speculate: Ci.nsIDNSService.RESOLVE_SPECULATE, +}; + +function getErrorString(nsresult) { + let e = new Components.Exception("", nsresult); + return e.name; +} + +this.dns = class extends ExtensionAPI { + getAPI(context) { + return { + dns: { + resolve: function (hostname, flags) { + let dnsFlags = flags.reduce( + (mask, flag) => mask | dnssFlags[flag], + 0 + ); + + return new Promise((resolve, reject) => { + let request; + let response = { + addresses: [], + }; + let listener = { + onLookupComplete: function (inRequest, inRecord, inStatus) { + if (inRequest === request) { + if (!Components.isSuccessCode(inStatus)) { + return reject({ message: getErrorString(inStatus) }); + } + inRecord.QueryInterface(Ci.nsIDNSAddrRecord); + if (dnsFlags & Ci.nsIDNSService.RESOLVE_CANONICAL_NAME) { + try { + response.canonicalName = inRecord.canonicalName; + } catch (e) { + // no canonicalName + } + } + response.isTRR = inRecord.IsTRR(); + while (inRecord.hasMore()) { + let addr = inRecord.getNextAddrAsString(); + // Sometimes there are duplicate records with the same ip. + if (!response.addresses.includes(addr)) { + response.addresses.push(addr); + } + } + return resolve(response); + } + }, + }; + try { + request = Services.dns.asyncResolve( + hostname, + Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT, + dnsFlags, + null, // AdditionalInfo + listener, + null, + {} /* defaultOriginAttributes */ + ); + } catch (e) { + // handle exceptions such as offline mode. + return reject({ message: e.name }); + } + }); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-downloads.js b/toolkit/components/extensions/parent/ext-downloads.js new file mode 100644 index 0000000000..9cd96e0d65 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-downloads.js @@ -0,0 +1,1261 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + DownloadLastDir: "resource://gre/modules/DownloadLastDir.sys.mjs", + DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs", + Downloads: "resource://gre/modules/Downloads.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); + +var { EventEmitter, ignoreEvent } = ExtensionCommon; +var { ExtensionError } = ExtensionUtils; + +const DOWNLOAD_ITEM_FIELDS = [ + "id", + "url", + "referrer", + "filename", + "incognito", + "cookieStoreId", + "danger", + "mime", + "startTime", + "endTime", + "estimatedEndTime", + "state", + "paused", + "canResume", + "error", + "bytesReceived", + "totalBytes", + "fileSize", + "exists", + "byExtensionId", + "byExtensionName", +]; + +const DOWNLOAD_DATE_FIELDS = ["startTime", "endTime", "estimatedEndTime"]; + +// Fields that we generate onChanged events for. +const DOWNLOAD_ITEM_CHANGE_FIELDS = [ + "endTime", + "state", + "paused", + "canResume", + "error", + "exists", +]; + +// From https://fetch.spec.whatwg.org/#forbidden-header-name +// Since bug 1367626 we allow extensions to set REFERER. +const FORBIDDEN_HEADERS = [ + "ACCEPT-CHARSET", + "ACCEPT-ENCODING", + "ACCESS-CONTROL-REQUEST-HEADERS", + "ACCESS-CONTROL-REQUEST-METHOD", + "CONNECTION", + "CONTENT-LENGTH", + "COOKIE", + "COOKIE2", + "DATE", + "DNT", + "EXPECT", + "HOST", + "KEEP-ALIVE", + "ORIGIN", + "TE", + "TRAILER", + "TRANSFER-ENCODING", + "UPGRADE", + "VIA", +]; + +const FORBIDDEN_PREFIXES = /^PROXY-|^SEC-/i; + +const PROMPTLESS_DOWNLOAD_PREF = "browser.download.useDownloadDir"; + +// Lists of file extensions for each file picker filter taken from filepicker.properties +const FILTER_HTML_EXTENSIONS = ["html", "htm", "shtml", "xhtml"]; + +const FILTER_TEXT_EXTENSIONS = ["txt", "text"]; + +const FILTER_IMAGES_EXTENSIONS = [ + "jpe", + "jpg", + "jpeg", + "gif", + "png", + "bmp", + "ico", + "svg", + "svgz", + "tif", + "tiff", + "ai", + "drw", + "pct", + "psp", + "xcf", + "psd", + "raw", + "webp", + "heic", +]; + +const FILTER_XML_EXTENSIONS = ["xml"]; + +const FILTER_AUDIO_EXTENSIONS = [ + "aac", + "aif", + "flac", + "iff", + "m4a", + "m4b", + "mid", + "midi", + "mp3", + "mpa", + "mpc", + "oga", + "ogg", + "ra", + "ram", + "snd", + "wav", + "wma", +]; + +const FILTER_VIDEO_EXTENSIONS = [ + "avi", + "divx", + "flv", + "m4v", + "mkv", + "mov", + "mp4", + "mpeg", + "mpg", + "ogm", + "ogv", + "ogx", + "rm", + "rmvb", + "smil", + "webm", + "wmv", + "xvid", +]; + +class DownloadItem { + constructor(id, download, extension) { + this.id = id; + this.download = download; + this.extension = extension; + this.prechange = {}; + this._error = null; + } + + get url() { + return this.download.source.url; + } + + get referrer() { + const uri = this.download.source.referrerInfo?.originalReferrer; + + return uri?.spec; + } + + get filename() { + return this.download.target.path; + } + + get incognito() { + return this.download.source.isPrivate; + } + + get cookieStoreId() { + if (this.download.source.isPrivate) { + return PRIVATE_STORE; + } + if (this.download.source.userContextId) { + return getCookieStoreIdForContainer(this.download.source.userContextId); + } + return DEFAULT_STORE; + } + + get danger() { + // TODO + return "safe"; + } + + get mime() { + return this.download.contentType; + } + + get startTime() { + return this.download.startTime; + } + + get endTime() { + // TODO bug 1256269: implement endTime. + return null; + } + + get estimatedEndTime() { + // Based on the code in summarizeDownloads() in DownloadsCommon.sys.mjs + if (this.download.hasProgress && this.download.speed > 0) { + let sizeLeft = this.download.totalBytes - this.download.currentBytes; + let timeLeftInSeconds = sizeLeft / this.download.speed; + return new Date(Date.now() + timeLeftInSeconds * 1000); + } + } + + get state() { + if (this.download.succeeded) { + return "complete"; + } + if (this.download.canceled || this.error) { + return "interrupted"; + } + return "in_progress"; + } + + get paused() { + return ( + this.download.canceled && + this.download.hasPartialData && + !this.download.error + ); + } + + get canResume() { + return ( + (this.download.stopped || this.download.canceled) && + this.download.hasPartialData && + !this.download.error + ); + } + + get error() { + if (this._error) { + return this._error; + } + if ( + !this.download.startTime || + !this.download.stopped || + this.download.succeeded + ) { + return null; + } + + // TODO store this instead of calculating it + if (this.download.error) { + if (this.download.error.becauseSourceFailed) { + return "NETWORK_FAILED"; // TODO + } + if (this.download.error.becauseTargetFailed) { + return "FILE_FAILED"; // TODO + } + return "CRASH"; + } + return "USER_CANCELED"; + } + + set error(value) { + this._error = value && value.toString(); + } + + get bytesReceived() { + return this.download.currentBytes; + } + + get totalBytes() { + return this.download.hasProgress ? this.download.totalBytes : -1; + } + + get fileSize() { + // todo: this is supposed to be post-compression + return this.download.succeeded ? this.download.target.size : -1; + } + + get exists() { + return this.download.target.exists; + } + + get byExtensionId() { + return this.extension?.id; + } + + get byExtensionName() { + return this.extension?.name; + } + + /** + * Create a cloneable version of this object by pulling all the + * fields into simple properties (instead of getters). + * + * @returns {object} A DownloadItem with flat properties, + * suitable for cloning. + */ + serialize() { + let obj = {}; + for (let field of DOWNLOAD_ITEM_FIELDS) { + obj[field] = this[field]; + } + for (let field of DOWNLOAD_DATE_FIELDS) { + if (obj[field]) { + obj[field] = obj[field].toISOString(); + } + } + return obj; + } + + // When a change event fires, handlers can look at how an individual + // field changed by comparing item.fieldname with item.prechange.fieldname. + // After all handlers have been invoked, this gets called to store the + // current values of all fields ahead of the next event. + _storePrechange() { + for (let field of DOWNLOAD_ITEM_CHANGE_FIELDS) { + this.prechange[field] = this[field]; + } + } +} + +// DownloadMap maps back and forth between the numeric identifiers used in +// the downloads WebExtension API and a Download object from the Downloads sys.mjs. +// TODO Bug 1247794: make id and extension info persistent +const DownloadMap = new (class extends EventEmitter { + constructor() { + super(); + + this.currentId = 0; + this.loadPromise = null; + + // Maps numeric id -> DownloadItem + this.byId = new Map(); + + // Maps Download object -> DownloadItem + this.byDownload = new WeakMap(); + } + + lazyInit() { + if (!this.loadPromise) { + this.loadPromise = (async () => { + const list = await Downloads.getList(Downloads.ALL); + + await list.addView({ + onDownloadAdded: download => { + const item = this.newFromDownload(download, null); + this.emit("create", item); + item._storePrechange(); + }, + onDownloadRemoved: download => { + const item = this.byDownload.get(download); + if (item) { + this.emit("erase", item); + this.byDownload.delete(download); + this.byId.delete(item.id); + } + }, + onDownloadChanged: download => { + const item = this.byDownload.get(download); + if (item) { + this.emit("change", item); + item._storePrechange(); + } else { + Cu.reportError( + "Got onDownloadChanged for unknown download object" + ); + } + }, + }); + + const downloads = await list.getAll(); + + for (let download of downloads) { + this.newFromDownload(download, null); + } + + return list; + })(); + } + + return this.loadPromise; + } + + getDownloadList() { + return this.lazyInit(); + } + + async getAll() { + await this.lazyInit(); + return this.byId.values(); + } + + fromId(id, privateAllowed = true) { + const download = this.byId.get(id); + if (!download || (!privateAllowed && download.incognito)) { + throw new ExtensionError(`Invalid download id ${id}`); + } + return download; + } + + newFromDownload(download, extension) { + if (this.byDownload.has(download)) { + return this.byDownload.get(download); + } + + const id = ++this.currentId; + let item = new DownloadItem(id, download, extension); + this.byId.set(id, item); + this.byDownload.set(download, item); + return item; + } + + async erase(item) { + // TODO Bug 1255507: for now we only work with downloads in the DownloadList + // from getAll() + const list = await this.getDownloadList(); + list.remove(item.download); + } +})(); + +// Create a callable function that filters a DownloadItem based on a +// query object of the type passed to search() or erase(). +const downloadQuery = query => { + let queryTerms = []; + let queryNegativeTerms = []; + if (query.query != null) { + for (let term of query.query) { + if (term[0] == "-") { + queryNegativeTerms.push(term.slice(1).toLowerCase()); + } else { + queryTerms.push(term.toLowerCase()); + } + } + } + + function normalizeDownloadTime(arg, before) { + if (arg == null) { + return before ? Number.MAX_VALUE : 0; + } + return ExtensionCommon.normalizeTime(arg).getTime(); + } + + const startedBefore = normalizeDownloadTime(query.startedBefore, true); + const startedAfter = normalizeDownloadTime(query.startedAfter, false); + + // TODO bug 1727510: Implement endedBefore/endedAfter + // const endedBefore = normalizeDownloadTime(query.endedBefore, true); + // const endedAfter = normalizeDownloadTime(query.endedAfter, false); + + const totalBytesGreater = query.totalBytesGreater ?? -1; + const totalBytesLess = query.totalBytesLess ?? Number.MAX_VALUE; + + // Handle options for which we can have a regular expression and/or + // an explicit value to match. + function makeMatch(regex, value, field) { + if (value == null && regex == null) { + return input => true; + } + + let re; + try { + re = new RegExp(regex || "", "i"); + } catch (err) { + throw new ExtensionError(`Invalid ${field}Regex: ${err.message}`); + } + if (value == null) { + return input => re.test(input); + } + + value = value.toLowerCase(); + if (re.test(value)) { + return input => value == input; + } + return input => false; + } + + const matchFilename = makeMatch( + query.filenameRegex, + query.filename, + "filename" + ); + const matchUrl = makeMatch(query.urlRegex, query.url, "url"); + + return function (item) { + const url = item.url.toLowerCase(); + const filename = item.filename.toLowerCase(); + + if ( + !queryTerms.every(term => url.includes(term) || filename.includes(term)) + ) { + return false; + } + + if ( + queryNegativeTerms.some( + term => url.includes(term) || filename.includes(term) + ) + ) { + return false; + } + + if (!matchFilename(filename) || !matchUrl(url)) { + return false; + } + + if (!item.startTime) { + if (query.startedBefore != null || query.startedAfter != null) { + return false; + } + } else if ( + item.startTime > startedBefore || + item.startTime < startedAfter + ) { + return false; + } + + // todo endedBefore, endedAfter + + if (item.totalBytes == -1) { + if (query.totalBytesGreater !== null || query.totalBytesLess !== null) { + return false; + } + } else if ( + item.totalBytes <= totalBytesGreater || + item.totalBytes >= totalBytesLess + ) { + return false; + } + + // todo: include danger + const SIMPLE_ITEMS = [ + "id", + "mime", + "startTime", + "endTime", + "state", + "paused", + "error", + "incognito", + "cookieStoreId", + "bytesReceived", + "totalBytes", + "fileSize", + "exists", + ]; + for (let field of SIMPLE_ITEMS) { + if (query[field] != null && item[field] != query[field]) { + return false; + } + } + + return true; + }; +}; + +const queryHelper = async query => { + let matchFn = downloadQuery(query); + let compareFn; + + if (query.orderBy) { + const fields = query.orderBy.map(field => + field[0] == "-" + ? { reverse: true, name: field.slice(1) } + : { reverse: false, name: field } + ); + + for (let field of fields) { + if (!DOWNLOAD_ITEM_FIELDS.includes(field.name)) { + throw new ExtensionError(`Invalid orderBy field ${field.name}`); + } + } + + compareFn = (dl1, dl2) => { + for (let field of fields) { + const val1 = dl1[field.name]; + const val2 = dl2[field.name]; + + if (val1 < val2) { + return field.reverse ? 1 : -1; + } else if (val1 > val2) { + return field.reverse ? -1 : 1; + } + } + return 0; + }; + } + + let downloads = await DownloadMap.getAll(); + + if (compareFn) { + downloads = Array.from(downloads); + downloads.sort(compareFn); + } + + let results = []; + for (let download of downloads) { + if (query.limit && results.length >= query.limit) { + break; + } + if (matchFn(download)) { + results.push(download); + } + } + return results; +}; + +this.downloads = class extends ExtensionAPIPersistent { + downloadEventRegistrar(event, listener) { + let { extension } = this; + return ({ fire }) => { + const handler = (what, item) => { + if (extension.privateBrowsingAllowed || !item.incognito) { + listener(fire, what, item); + } + }; + let registerPromise = DownloadMap.getDownloadList().then(() => { + DownloadMap.on(event, handler); + }); + return { + unregister() { + registerPromise.then(() => { + DownloadMap.off(event, handler); + }); + }, + convert(_fire) { + fire = _fire; + }, + }; + }; + } + + PERSISTENT_EVENTS = { + onChanged: this.downloadEventRegistrar("change", (fire, what, item) => { + let changes = {}; + const noundef = val => (val === undefined ? null : val); + DOWNLOAD_ITEM_CHANGE_FIELDS.forEach(fld => { + if (item[fld] != item.prechange[fld]) { + changes[fld] = { + previous: noundef(item.prechange[fld]), + current: noundef(item[fld]), + }; + } + }); + if (Object.keys(changes).length) { + changes.id = item.id; + fire.async(changes); + } + }), + + onCreated: this.downloadEventRegistrar("create", (fire, what, item) => { + fire.async(item.serialize()); + }), + + onErased: this.downloadEventRegistrar("erase", (fire, what, item) => { + fire.async(item.id); + }), + }; + + getAPI(context) { + let { extension } = context; + return { + downloads: { + async download(options) { + const isHandlingUserInput = + context.callContextData?.isHandlingUserInput; + let { filename } = options; + if (filename && AppConstants.platform === "win") { + // cross platform javascript code uses "/" + filename = filename.replace(/\//g, "\\"); + } + + if (filename != null) { + if (!filename.length) { + throw new ExtensionError("filename must not be empty"); + } + + if (PathUtils.isAbsolute(filename)) { + throw new ExtensionError("filename must not be an absolute path"); + } + + const pathComponents = PathUtils.splitRelative(filename, { + allowEmpty: true, + allowCurrentDir: true, + allowParentDir: true, + }); + + if (pathComponents.some(component => component == "..")) { + throw new ExtensionError( + "filename must not contain back-references (..)" + ); + } + + if ( + pathComponents.some(component => { + let sanitized = DownloadPaths.sanitize(component, { + compressWhitespaces: false, + }); + return component != sanitized; + }) + ) { + throw new ExtensionError( + "filename must not contain illegal characters" + ); + } + } + + if (options.incognito && !context.privateBrowsingAllowed) { + throw new ExtensionError("private browsing access not allowed"); + } + + if (options.conflictAction == "prompt") { + // TODO + throw new ExtensionError( + "conflictAction prompt not yet implemented" + ); + } + + if (options.headers) { + for (let { name } of options.headers) { + if ( + FORBIDDEN_HEADERS.includes(name.toUpperCase()) || + name.match(FORBIDDEN_PREFIXES) + ) { + throw new ExtensionError("Forbidden request header name"); + } + } + } + + let userContextId = null; + if (options.cookieStoreId != null) { + userContextId = getUserContextIdForCookieStoreId( + extension, + options.cookieStoreId, + options.incognito + ); + } + + // Handle method, headers and body options. + function adjustChannel(channel) { + if (channel instanceof Ci.nsIHttpChannel) { + const method = options.method || "GET"; + channel.requestMethod = method; + + if (options.headers) { + for (let { name, value } of options.headers) { + if (name.toLowerCase() == "referer") { + // The referer header and referrerInfo object should always + // match. So if we want to set the header from privileged + // context, we should set referrerInfo. The referrer header + // will get set internally. + channel.setNewReferrerInfo( + value, + Ci.nsIReferrerInfo.UNSAFE_URL, + true + ); + } else { + channel.setRequestHeader(name, value, false); + } + } + } + + if (options.body != null) { + const stream = Cc[ + "@mozilla.org/io/string-input-stream;1" + ].createInstance(Ci.nsIStringInputStream); + stream.setData(options.body, options.body.length); + + channel.QueryInterface(Ci.nsIUploadChannel2); + channel.explicitSetUploadStream( + stream, + null, + -1, + method, + false + ); + } + } + return Promise.resolve(); + } + + function allowHttpStatus(download, status) { + const item = DownloadMap.byDownload.get(download); + if (item === null) { + return true; + } + + let error = null; + switch (status) { + case 204: // No Content + case 205: // Reset Content + case 404: // Not Found + error = "SERVER_BAD_CONTENT"; + break; + + case 403: // Forbidden + error = "SERVER_FORBIDDEN"; + break; + + case 402: // Unauthorized + case 407: // Proxy authentication required + error = "SERVER_UNAUTHORIZED"; + break; + + default: + if (status >= 400) { + error = "SERVER_FAILED"; + } + break; + } + + if (error) { + item.error = error; + return false; + } + + // No error, ergo allow the request. + return true; + } + + async function createTarget(downloadsDir) { + if (!filename) { + let uri = Services.io.newURI(options.url); + if (uri instanceof Ci.nsIURL) { + filename = DownloadPaths.sanitize( + Services.textToSubURI.unEscapeURIForUI( + uri.fileName, + /* dontEscape = */ true + ) + ); + } + } + + let target = PathUtils.joinRelative( + downloadsDir, + filename || "download" + ); + + let saveAs; + if (options.saveAs !== null) { + saveAs = options.saveAs; + } else { + // If options.saveAs was not specified, only show the file chooser + // if |browser.download.useDownloadDir == false|. That is to say, + // only show the file chooser if Firefox normally shows it when + // a file is downloaded. + saveAs = !Services.prefs.getBoolPref( + PROMPTLESS_DOWNLOAD_PREF, + true + ); + } + + // Create any needed subdirectories if required by filename. + const dir = PathUtils.parent(target); + await IOUtils.makeDirectory(dir); + + if (await IOUtils.exists(target)) { + // This has a race, something else could come along and create + // the file between this test and them time the download code + // creates the target file. But we can't easily fix it without + // modifying DownloadCore so we live with it for now. + switch (options.conflictAction) { + case "uniquify": + default: + target = DownloadPaths.createNiceUniqueFile( + new FileUtils.File(target) + ).path; + if (saveAs) { + // createNiceUniqueFile actually creates the file, which + // is premature if we need to show a SaveAs dialog. + await IOUtils.remove(target); + } + break; + + case "overwrite": + break; + } + } + + if (!saveAs || AppConstants.platform === "android") { + return target; + } + + if (!("windowTracker" in global)) { + return target; + } + + // At this point we are committed to displaying the file picker. + const downloadLastDir = new DownloadLastDir( + null, + options.incognito + ); + + async function getLastDirectory() { + return downloadLastDir.getFileAsync(extension.baseURI); + } + + function appendFilterForFileExtension(picker, ext) { + if (FILTER_HTML_EXTENSIONS.includes(ext)) { + picker.appendFilters(Ci.nsIFilePicker.filterHTML); + } else if (FILTER_TEXT_EXTENSIONS.includes(ext)) { + picker.appendFilters(Ci.nsIFilePicker.filterText); + } else if (FILTER_IMAGES_EXTENSIONS.includes(ext)) { + picker.appendFilters(Ci.nsIFilePicker.filterImages); + } else if (FILTER_XML_EXTENSIONS.includes(ext)) { + picker.appendFilters(Ci.nsIFilePicker.filterXML); + } else if (FILTER_AUDIO_EXTENSIONS.includes(ext)) { + picker.appendFilters(Ci.nsIFilePicker.filterAudio); + } else if (FILTER_VIDEO_EXTENSIONS.includes(ext)) { + picker.appendFilters(Ci.nsIFilePicker.filterVideo); + } + } + + function saveLastDirectory(lastDir) { + downloadLastDir.setFile(extension.baseURI, lastDir); + } + + // Use windowTracker to find a window, rather than Services.wm, + // so that this doesn't break where navigator:browser isn't the + // main window (e.g. Thunderbird). + const window = global.windowTracker.getTopWindow().window; + const basename = PathUtils.filename(target); + const ext = basename.match(/\.([^.]+)$/)?.[1]; + + // If the filename passed in by the extension is a simple name + // and not a path, we open the file picker so it displays the + // last directory that was chosen by the user. + const pathSep = AppConstants.platform === "win" ? "\\" : "/"; + const lastFilePickerDirectory = + !filename || !filename.includes(pathSep) + ? await getLastDirectory() + : undefined; + + // Setup the file picker Save As dialog. + const picker = Cc["@mozilla.org/filepicker;1"].createInstance( + Ci.nsIFilePicker + ); + picker.init(window, null, Ci.nsIFilePicker.modeSave); + if (lastFilePickerDirectory) { + picker.displayDirectory = lastFilePickerDirectory; + } else { + picker.displayDirectory = new FileUtils.File(dir); + } + picker.defaultString = basename; + if (ext) { + // Configure a default file extension, used as fallback on Windows. + picker.defaultExtension = ext; + appendFilterForFileExtension(picker, ext); + } + picker.appendFilters(Ci.nsIFilePicker.filterAll); + + // Open the dialog and resolve/reject with the result. + return new Promise((resolve, reject) => { + picker.open(result => { + if (result === Ci.nsIFilePicker.returnCancel) { + reject({ message: "Download canceled by the user" }); + } else { + saveLastDirectory(picker.file.parent); + resolve(picker.file.path); + } + }); + }); + } + + const downloadsDir = await Downloads.getPreferredDownloadsDirectory(); + const target = await createTarget(downloadsDir); + const uri = Services.io.newURI(options.url); + const cookieJarSettings = Cc[ + "@mozilla.org/cookieJarSettings;1" + ].createInstance(Ci.nsICookieJarSettings); + cookieJarSettings.initWithURI(uri, options.incognito); + + const source = { + url: options.url, + isPrivate: options.incognito, + // Use the extension's principal to allow extensions to observe + // their own downloads via the webRequest API. + loadingPrincipal: context.principal, + cookieJarSettings, + }; + + if (userContextId) { + source.userContextId = userContextId; + } + + // blob:-URLs can only be loaded by the principal with which they + // are associated. This principal may have origin attributes. + // `context.principal` does sometimes not have these attributes + // due to bug 1653681. If `context.principal` were to be passed, + // the download request would be rejected because of mismatching + // principals (origin attributes). + // TODO bug 1653681: fix context.principal and remove this. + if (options.url.startsWith("blob:")) { + // To make sure that the blob:-URL can be loaded, fall back to + // the default (system) principal instead. + delete source.loadingPrincipal; + } + + // Unless the API user explicitly wants errors ignored, + // set the allowHttpStatus callback, which will instruct + // DownloadCore to cancel downloads on HTTP errors. + if (!options.allowHttpErrors) { + source.allowHttpStatus = allowHttpStatus; + } + + if (options.method || options.headers || options.body) { + source.adjustChannel = adjustChannel; + } + + const download = await Downloads.createDownload({ + // Only open the download panel if the method has been called + // while handling user input (See Bug 1759231). + openDownloadsListOnStart: isHandlingUserInput, + source, + target: { + path: target, + partFilePath: `${target}.part`, + }, + }); + + const list = await DownloadMap.getDownloadList(); + const item = DownloadMap.newFromDownload(download, extension); + list.add(download); + + // This is necessary to make pause/resume work. + download.tryToKeepPartialData = true; + + // Do not handle errors. + // Extensions will use listeners to be informed about errors. + // Just ignore any errors from |start()| to avoid spamming the + // error console. + download.start().catch(err => { + if (err.name !== "DownloadError") { + Cu.reportError(err); + } + }); + + return item.id; + }, + + async removeFile(id) { + await DownloadMap.lazyInit(); + + let item = DownloadMap.fromId(id, context.privateBrowsingAllowed); + + if (item.state !== "complete") { + throw new ExtensionError( + `Cannot remove incomplete download id ${id}` + ); + } + + try { + await IOUtils.remove(item.filename, { ignoreAbsent: false }); + } catch (err) { + if (DOMException.isInstance(err) && err.name === "NotFoundError") { + throw new ExtensionError( + `Could not remove download id ${item.id} because the file doesn't exist` + ); + } + + // Unexpected other error. Throw the original error, so that it + // can bubble up to the global browser console, but keep it + // sanitized (i.e. not wrapped in ExtensionError) to avoid + // inadvertent disclosure of potentially sensitive information. + throw err; + } + }, + + async search(query) { + if (!context.privateBrowsingAllowed) { + query.incognito = false; + } + + const items = await queryHelper(query); + return items.map(item => item.serialize()); + }, + + async pause(id) { + await DownloadMap.lazyInit(); + + let item = DownloadMap.fromId(id, context.privateBrowsingAllowed); + + if (item.state !== "in_progress") { + throw new ExtensionError( + `Download ${id} cannot be paused since it is in state ${item.state}` + ); + } + + return item.download.cancel(); + }, + + async resume(id) { + await DownloadMap.lazyInit(); + + let item = DownloadMap.fromId(id, context.privateBrowsingAllowed); + + if (!item.canResume) { + throw new ExtensionError(`Download ${id} cannot be resumed`); + } + + item.error = null; + return item.download.start(); + }, + + async cancel(id) { + await DownloadMap.lazyInit(); + + let item = DownloadMap.fromId(id, context.privateBrowsingAllowed); + + if (item.download.succeeded) { + throw new ExtensionError(`Download ${id} is already complete`); + } + + return item.download.finalize(true); + }, + + showDefaultFolder() { + Downloads.getPreferredDownloadsDirectory() + .then(dir => { + let dirobj = new FileUtils.File(dir); + if (dirobj.isDirectory()) { + dirobj.launch(); + } else { + throw new Error( + `Download directory ${dirobj.path} is not actually a directory` + ); + } + }) + .catch(Cu.reportError); + }, + + async erase(query) { + if (!context.privateBrowsingAllowed) { + query.incognito = false; + } + + const items = await queryHelper(query); + let results = []; + let promises = []; + + for (let item of items) { + promises.push(DownloadMap.erase(item)); + results.push(item.id); + } + + await Promise.all(promises); + return results; + }, + + async open(downloadId) { + await DownloadMap.lazyInit(); + + let { download } = DownloadMap.fromId( + downloadId, + context.privateBrowsingAllowed + ); + + if (!download.succeeded) { + throw new ExtensionError("Download has not completed."); + } + + return download.launch(); + }, + + async show(downloadId) { + await DownloadMap.lazyInit(); + + const { download } = DownloadMap.fromId( + downloadId, + context.privateBrowsingAllowed + ); + + await download.showContainingDirectory(); + + return true; + }, + + async getFileIcon(downloadId, options) { + await DownloadMap.lazyInit(); + + const size = options?.size || 32; + const { download } = DownloadMap.fromId( + downloadId, + context.privateBrowsingAllowed + ); + + let pathPrefix = ""; + let path; + + if (download.succeeded) { + let file = FileUtils.File(download.target.path); + path = Services.io.newFileURI(file).spec; + } else { + path = PathUtils.filename(download.target.path); + pathPrefix = "//"; + } + + let windowlessBrowser = + Services.appShell.createWindowlessBrowser(true); + let systemPrincipal = + Services.scriptSecurityManager.getSystemPrincipal(); + windowlessBrowser.docShell.createAboutBlankDocumentViewer( + systemPrincipal, + systemPrincipal + ); + + let canvas = windowlessBrowser.document.createElement("canvas"); + let img = new windowlessBrowser.docShell.domWindow.Image(size, size); + + canvas.width = size; + canvas.height = size; + + img.src = `moz-icon:${pathPrefix}${path}?size=${size}`; + + try { + await img.decode(); + + canvas.getContext("2d").drawImage(img, 0, 0, size, size); + + let dataURL = canvas.toDataURL("image/png"); + + return dataURL; + } finally { + windowlessBrowser.close(); + } + }, + + onChanged: new EventManager({ + context, + module: "downloads", + event: "onChanged", + extensionApi: this, + }).api(), + + onCreated: new EventManager({ + context, + module: "downloads", + event: "onCreated", + extensionApi: this, + }).api(), + + onErased: new EventManager({ + context, + module: "downloads", + event: "onErased", + extensionApi: this, + }).api(), + + onDeterminingFilename: ignoreEvent( + context, + "downloads.onDeterminingFilename" + ), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-extension.js b/toolkit/components/extensions/parent/ext-extension.js new file mode 100644 index 0000000000..2f0a168dd4 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-extension.js @@ -0,0 +1,25 @@ +/* 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"; + +this.extension = class extends ExtensionAPI { + getAPI(context) { + return { + extension: { + get lastError() { + return context.lastError; + }, + + isAllowedIncognitoAccess() { + return context.privateBrowsingAllowed; + }, + + isAllowedFileSchemeAccess() { + return false; + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-geckoProfiler.js b/toolkit/components/extensions/parent/ext-geckoProfiler.js new file mode 100644 index 0000000000..91f2e6e594 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-geckoProfiler.js @@ -0,0 +1,191 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +const PREF_ASYNC_STACK = "javascript.options.asyncstack"; + +const ASYNC_STACKS_ENABLED = Services.prefs.getBoolPref( + PREF_ASYNC_STACK, + false +); + +var { ExtensionError } = ExtensionUtils; + +ChromeUtils.defineLazyGetter(this, "symbolicationService", () => { + let { createLocalSymbolicationService } = ChromeUtils.importESModule( + "resource://devtools/client/performance-new/shared/symbolication.sys.mjs" + ); + return createLocalSymbolicationService(Services.profiler.sharedLibraries, []); +}); + +const isRunningObserver = { + _observers: new Set(), + + observe(subject, topic, data) { + switch (topic) { + case "profiler-started": + case "profiler-stopped": + // Call observer(false) or observer(true), but do it through a promise + // so that it's asynchronous. + // We don't want it to be synchronous because of the observer call in + // addObserver, which is asynchronous, and we want to get the ordering + // right. + const isRunningPromise = Promise.resolve(topic === "profiler-started"); + for (let observer of this._observers) { + isRunningPromise.then(observer); + } + break; + } + }, + + _startListening() { + Services.obs.addObserver(this, "profiler-started"); + Services.obs.addObserver(this, "profiler-stopped"); + }, + + _stopListening() { + Services.obs.removeObserver(this, "profiler-started"); + Services.obs.removeObserver(this, "profiler-stopped"); + }, + + addObserver(observer) { + if (this._observers.size === 0) { + this._startListening(); + } + + this._observers.add(observer); + observer(Services.profiler.IsActive()); + }, + + removeObserver(observer) { + if (this._observers.delete(observer) && this._observers.size === 0) { + this._stopListening(); + } + }, +}; + +this.geckoProfiler = class extends ExtensionAPI { + getAPI(context) { + return { + geckoProfiler: { + async start(options) { + const { bufferSize, windowLength, interval, features, threads } = + options; + + Services.prefs.setBoolPref(PREF_ASYNC_STACK, false); + if (threads) { + Services.profiler.StartProfiler( + bufferSize, + interval, + features, + threads, + 0, + windowLength + ); + } else { + Services.profiler.StartProfiler( + bufferSize, + interval, + features, + [], + 0, + windowLength + ); + } + }, + + async stop() { + if (ASYNC_STACKS_ENABLED !== null) { + Services.prefs.setBoolPref(PREF_ASYNC_STACK, ASYNC_STACKS_ENABLED); + } + + Services.profiler.StopProfiler(); + }, + + async pause() { + Services.profiler.Pause(); + }, + + async resume() { + Services.profiler.Resume(); + }, + + async dumpProfileToFile(fileName) { + if (!Services.profiler.IsActive()) { + throw new ExtensionError( + "The profiler is stopped. " + + "You need to start the profiler before you can capture a profile." + ); + } + + if (fileName.includes("\\") || fileName.includes("/")) { + throw new ExtensionError("Path cannot contain a subdirectory."); + } + + let dirPath = PathUtils.join(PathUtils.profileDir, "profiler"); + let filePath = PathUtils.join(dirPath, fileName); + + try { + await IOUtils.makeDirectory(dirPath); + await Services.profiler.dumpProfileToFileAsync(filePath); + } catch (e) { + Cu.reportError(e); + throw new ExtensionError(`Dumping profile to ${filePath} failed.`); + } + }, + + async getProfile() { + if (!Services.profiler.IsActive()) { + throw new ExtensionError( + "The profiler is stopped. " + + "You need to start the profiler before you can capture a profile." + ); + } + + return Services.profiler.getProfileDataAsync(); + }, + + async getProfileAsArrayBuffer() { + if (!Services.profiler.IsActive()) { + throw new ExtensionError( + "The profiler is stopped. " + + "You need to start the profiler before you can capture a profile." + ); + } + + return Services.profiler.getProfileDataAsArrayBuffer(); + }, + + async getProfileAsGzippedArrayBuffer() { + if (!Services.profiler.IsActive()) { + throw new ExtensionError( + "The profiler is stopped. " + + "You need to start the profiler before you can capture a profile." + ); + } + + return Services.profiler.getProfileDataAsGzippedArrayBuffer(); + }, + + async getSymbols(debugName, breakpadId) { + return symbolicationService.getSymbolTable(debugName, breakpadId); + }, + + onRunning: new EventManager({ + context, + name: "geckoProfiler.onRunning", + register: fire => { + isRunningObserver.addObserver(fire.async); + return () => { + isRunningObserver.removeObserver(fire.async); + }; + }, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-i18n.js b/toolkit/components/extensions/parent/ext-i18n.js new file mode 100644 index 0000000000..167a1d16c2 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-i18n.js @@ -0,0 +1,46 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + LanguageDetector: + "resource://gre/modules/translation/LanguageDetector.sys.mjs", +}); + +this.i18n = class extends ExtensionAPI { + getAPI(context) { + let { extension } = context; + return { + i18n: { + getMessage: function (messageName, substitutions) { + return extension.localizeMessage(messageName, substitutions, { + cloneScope: context.cloneScope, + }); + }, + + getAcceptLanguages: function () { + let result = extension.localeData.acceptLanguages; + return Promise.resolve(result); + }, + + getUILanguage: function () { + return extension.localeData.uiLocale; + }, + + detectLanguage: function (text) { + return LanguageDetector.detectLanguage(text).then(result => ({ + isReliable: result.confident, + languages: result.languages.map(lang => { + return { + language: lang.languageCode, + percentage: lang.percent, + }; + }), + })); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-identity.js b/toolkit/components/extensions/parent/ext-identity.js new file mode 100644 index 0000000000..5bc643811a --- /dev/null +++ b/toolkit/components/extensions/parent/ext-identity.js @@ -0,0 +1,152 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +XPCOMUtils.defineLazyGlobalGetters(this, ["XMLHttpRequest", "ChannelWrapper"]); + +var { promiseDocumentLoaded } = ExtensionUtils; + +const checkRedirected = (url, redirectURI) => { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.open("GET", url); + // We expect this if the user has not authenticated. + xhr.onload = () => { + reject(0); + }; + // An unexpected error happened, log for extension authors. + xhr.onerror = () => { + reject(xhr.status); + }; + // Catch redirect to our redirect_uri before a new request is made. + xhr.channel.notificationCallbacks = { + QueryInterface: ChromeUtils.generateQI([ + "nsIInterfaceRequestor", + "nsIChannelEventSync", + ]), + + getInterface: ChromeUtils.generateQI(["nsIChannelEventSink"]), + + asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) { + let responseURL = newChannel.URI.spec; + if (responseURL.startsWith(redirectURI)) { + resolve(responseURL); + // Cancel the redirect. + callback.onRedirectVerifyCallback(Cr.NS_BINDING_ABORTED); + return; + } + callback.onRedirectVerifyCallback(Cr.NS_OK); + }, + }; + xhr.send(); + }); +}; + +const openOAuthWindow = (details, redirectURI) => { + let args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray); + let supportsStringPrefURL = Cc[ + "@mozilla.org/supports-string;1" + ].createInstance(Ci.nsISupportsString); + supportsStringPrefURL.data = details.url; + args.appendElement(supportsStringPrefURL); + + let window = Services.ww.openWindow( + null, + AppConstants.BROWSER_CHROME_URL, + "launchWebAuthFlow_dialog", + "chrome,location=yes,centerscreen,dialog=no,resizable=yes,scrollbars=yes", + args + ); + + return new Promise((resolve, reject) => { + let httpActivityDistributor = Cc[ + "@mozilla.org/network/http-activity-distributor;1" + ].getService(Ci.nsIHttpActivityDistributor); + + let unloadListener; + let httpObserver; + + const resolveIfRedirectURI = channel => { + const url = channel.URI && channel.URI.spec; + if (!url || !url.startsWith(redirectURI)) { + return; + } + + // Early exit if channel isn't related to the oauth dialog. + let wrapper = ChannelWrapper.get(channel); + if ( + !wrapper.browserElement && + wrapper.browserElement !== window.gBrowser.selectedBrowser + ) { + return; + } + + wrapper.cancel(Cr.NS_ERROR_ABORT, Ci.nsILoadInfo.BLOCKING_REASON_NONE); + window.gBrowser.webNavigation.stop(Ci.nsIWebNavigation.STOP_ALL); + window.removeEventListener("unload", unloadListener); + httpActivityDistributor.removeObserver(httpObserver); + window.close(); + resolve(url); + }; + + httpObserver = { + observeActivity(channel, type, subtype, timestamp, sizeData, stringData) { + try { + channel.QueryInterface(Ci.nsIChannel); + } catch { + // Ignore activities for channels that doesn't implement nsIChannel + // (e.g. a NullHttpChannel). + return; + } + + resolveIfRedirectURI(channel); + }, + }; + + httpActivityDistributor.addObserver(httpObserver); + + // If the user just closes the window we need to reject + unloadListener = () => { + window.removeEventListener("unload", unloadListener); + httpActivityDistributor.removeObserver(httpObserver); + reject({ message: "User cancelled or denied access." }); + }; + + promiseDocumentLoaded(window.document).then(() => { + window.addEventListener("unload", unloadListener); + }); + }); +}; + +this.identity = class extends ExtensionAPI { + getAPI(context) { + return { + identity: { + launchWebAuthFlowInParent: function (details, redirectURI) { + // If the request is automatically redirected the user has already + // authorized and we do not want to show the window. + return checkRedirected(details.url, redirectURI).catch( + requestError => { + // requestError is zero or xhr.status + if (requestError !== 0) { + Cu.reportError( + `browser.identity auth check failed with ${requestError}` + ); + return Promise.reject({ message: "Invalid request" }); + } + if (!details.interactive) { + return Promise.reject({ message: `Requires user interaction` }); + } + + return openOAuthWindow(details, redirectURI); + } + ); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-idle.js b/toolkit/components/extensions/parent/ext-idle.js new file mode 100644 index 0000000000..f68ea293d7 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-idle.js @@ -0,0 +1,113 @@ +/* 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"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "idleService", + "@mozilla.org/widget/useridleservice;1", + "nsIUserIdleService" +); + +var { DefaultWeakMap } = ExtensionUtils; + +// WeakMap[Extension -> Object] +const idleObserversMap = new DefaultWeakMap(() => { + return { + observer: null, + detectionInterval: 60, + }; +}); + +const getIdleObserver = extension => { + let observerInfo = idleObserversMap.get(extension); + let { observer, detectionInterval } = observerInfo; + let interval = + extension.startupData?.idleDetectionInterval || detectionInterval; + + if (!observer) { + observer = new (class extends ExtensionCommon.EventEmitter { + observe(subject, topic, data) { + if (topic == "idle" || topic == "active") { + this.emit("stateChanged", topic); + } + } + })(); + idleService.addIdleObserver(observer, interval); + observerInfo.observer = observer; + observerInfo.detectionInterval = interval; + } + return observer; +}; + +this.idle = class extends ExtensionAPIPersistent { + PERSISTENT_EVENTS = { + onStateChanged({ fire }) { + let { extension } = this; + let listener = (event, data) => { + fire.sync(data); + }; + + getIdleObserver(extension).on("stateChanged", listener); + return { + async unregister() { + let observerInfo = idleObserversMap.get(extension); + let { observer, detectionInterval } = observerInfo; + if (observer) { + observer.off("stateChanged", listener); + if (!observer.has("stateChanged")) { + idleService.removeIdleObserver(observer, detectionInterval); + observerInfo.observer = null; + } + } + }, + convert(_fire) { + fire = _fire; + }, + }; + }, + }; + + getAPI(context) { + let { extension } = context; + let self = this; + + return { + idle: { + queryState(detectionIntervalInSeconds) { + if (idleService.idleTime < detectionIntervalInSeconds * 1000) { + return "active"; + } + return "idle"; + }, + setDetectionInterval(detectionIntervalInSeconds) { + let observerInfo = idleObserversMap.get(extension); + let { observer, detectionInterval } = observerInfo; + if (detectionInterval == detectionIntervalInSeconds) { + return; + } + if (observer) { + idleService.removeIdleObserver(observer, detectionInterval); + idleService.addIdleObserver(observer, detectionIntervalInSeconds); + } + observerInfo.detectionInterval = detectionIntervalInSeconds; + // There is no great way to modify a persistent listener param, but we + // need to keep this for the startup listener. + if (!extension.persistentBackground) { + extension.startupData.idleDetectionInterval = + detectionIntervalInSeconds; + extension.saveStartupData(); + } + }, + onStateChanged: new EventManager({ + context, + module: "idle", + event: "onStateChanged", + extensionApi: self, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-management.js b/toolkit/components/extensions/parent/ext-management.js new file mode 100644 index 0000000000..e0834d378f --- /dev/null +++ b/toolkit/components/extensions/parent/ext-management.js @@ -0,0 +1,354 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +ChromeUtils.defineLazyGetter(this, "strBundle", function () { + return Services.strings.createBundle( + "chrome://global/locale/extensions.properties" + ); +}); +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", +}); + +// We can't use Services.prompt here at the moment, as tests need to mock +// the prompt service. We could use sinon, but that didn't seem to work +// with Android builds. +// eslint-disable-next-line mozilla/use-services +XPCOMUtils.defineLazyServiceGetter( + this, + "promptService", + "@mozilla.org/prompter;1", + "nsIPromptService" +); + +var { ExtensionError } = ExtensionUtils; + +const _ = (key, ...args) => { + if (args.length) { + return strBundle.formatStringFromName(key, args); + } + return strBundle.GetStringFromName(key); +}; + +const installType = addon => { + if (addon.temporarilyInstalled) { + return "development"; + } else if (addon.foreignInstall) { + return "sideload"; + } else if (addon.isSystem) { + return "other"; + } + return "normal"; +}; + +const getExtensionInfoForAddon = (extension, addon) => { + let extInfo = { + id: addon.id, + name: addon.name, + description: addon.description || "", + version: addon.version, + mayDisable: !!(addon.permissions & AddonManager.PERM_CAN_DISABLE), + enabled: addon.isActive, + optionsUrl: addon.optionsURL || "", + installType: installType(addon), + type: addon.type, + }; + + if (extension) { + let m = extension.manifest; + + let hostPerms = extension.allowedOrigins.patterns.map( + matcher => matcher.pattern + ); + + extInfo.permissions = Array.from(extension.permissions).filter(perm => { + return !hostPerms.includes(perm); + }); + extInfo.hostPermissions = hostPerms; + + extInfo.shortName = m.short_name || ""; + if (m.icons) { + extInfo.icons = Object.keys(m.icons).map(key => { + return { size: Number(key), url: m.icons[key] }; + }); + } + } + + if (!addon.isActive) { + extInfo.disabledReason = "unknown"; + } + if (addon.homepageURL) { + extInfo.homepageUrl = addon.homepageURL; + } + if (addon.updateURL) { + extInfo.updateUrl = addon.updateURL; + } + return extInfo; +}; + +// Some management APIs are intentionally limited. +const allowedTypes = ["theme", "extension"]; + +function checkAllowedAddon(addon) { + if (addon.isSystem || addon.isAPIExtension) { + return false; + } + if (addon.type == "extension" && !addon.isWebExtension) { + return false; + } + return allowedTypes.includes(addon.type); +} + +class ManagementAddonListener extends ExtensionCommon.EventEmitter { + eventNames = ["onEnabled", "onDisabled", "onInstalled", "onUninstalled"]; + + hasAnyListeners() { + for (let event of this.eventNames) { + if (this.has(event)) { + return true; + } + } + return false; + } + + on(event, listener) { + if (!this.eventNames.includes(event)) { + throw new Error("unsupported event"); + } + if (!this.hasAnyListeners()) { + AddonManager.addAddonListener(this); + } + super.on(event, listener); + } + + off(event, listener) { + if (!this.eventNames.includes(event)) { + throw new Error("unsupported event"); + } + super.off(event, listener); + if (!this.hasAnyListeners()) { + AddonManager.removeAddonListener(this); + } + } + + getExtensionInfo(addon) { + let ext = WebExtensionPolicy.getByID(addon.id)?.extension; + return getExtensionInfoForAddon(ext, addon); + } + + onEnabled(addon) { + if (!checkAllowedAddon(addon)) { + return; + } + this.emit("onEnabled", this.getExtensionInfo(addon)); + } + + onDisabled(addon) { + if (!checkAllowedAddon(addon)) { + return; + } + this.emit("onDisabled", this.getExtensionInfo(addon)); + } + + onInstalled(addon) { + if (!checkAllowedAddon(addon)) { + return; + } + this.emit("onInstalled", this.getExtensionInfo(addon)); + } + + onUninstalled(addon) { + if (!checkAllowedAddon(addon)) { + return; + } + this.emit("onUninstalled", this.getExtensionInfo(addon)); + } +} + +this.management = class extends ExtensionAPIPersistent { + addonListener = new ManagementAddonListener(); + + onShutdown() { + AddonManager.removeAddonListener(this.addonListener); + } + + eventRegistrar(eventName) { + return ({ fire }) => { + let listener = (event, data) => { + fire.async(data); + }; + + this.addonListener.on(eventName, listener); + return { + unregister: () => { + this.addonListener.off(eventName, listener); + }, + convert(_fire, context) { + fire = _fire; + }, + }; + }; + } + + PERSISTENT_EVENTS = { + onDisabled: this.eventRegistrar("onDisabled"), + onEnabled: this.eventRegistrar("onEnabled"), + onInstalled: this.eventRegistrar("onInstalled"), + onUninstalled: this.eventRegistrar("onUninstalled"), + }; + + getAPI(context) { + let { extension } = context; + + return { + management: { + async get(id) { + let addon = await AddonManager.getAddonByID(id); + if (!addon) { + throw new ExtensionError(`No such addon ${id}`); + } + if (!checkAllowedAddon(addon)) { + throw new ExtensionError("get not allowed for this addon"); + } + // If the extension is enabled get it and use it for more data. + let ext = WebExtensionPolicy.getByID(addon.id)?.extension; + return getExtensionInfoForAddon(ext, addon); + }, + + async getAll() { + let addons = await AddonManager.getAddonsByTypes(allowedTypes); + return addons.filter(checkAllowedAddon).map(addon => { + // If the extension is enabled get it and use it for more data. + let ext = WebExtensionPolicy.getByID(addon.id)?.extension; + return getExtensionInfoForAddon(ext, addon); + }); + }, + + async install({ url, hash }) { + let listener = { + onDownloadEnded(install) { + if (install.addon.appDisabled || install.addon.type !== "theme") { + install.cancel(); + return false; + } + }, + }; + + let telemetryInfo = { + source: "extension", + method: "management-webext-api", + }; + let install = await AddonManager.getInstallForURL(url, { + hash, + telemetryInfo, + triggeringPrincipal: extension.principal, + }); + install.addListener(listener); + try { + await install.install(); + } catch (e) { + Cu.reportError(e); + throw new ExtensionError("Incompatible addon"); + } + await install.addon.enable(); + return { id: install.addon.id }; + }, + + async getSelf() { + let addon = await AddonManager.getAddonByID(extension.id); + return getExtensionInfoForAddon(extension, addon); + }, + + async uninstallSelf(options) { + if (options && options.showConfirmDialog) { + let message = _("uninstall.confirmation.message", extension.name); + if (options.dialogMessage) { + message = `${options.dialogMessage}\n${message}`; + } + let title = _("uninstall.confirmation.title", extension.name); + let buttonFlags = + Ci.nsIPrompt.BUTTON_POS_0 * Ci.nsIPrompt.BUTTON_TITLE_IS_STRING + + Ci.nsIPrompt.BUTTON_POS_1 * Ci.nsIPrompt.BUTTON_TITLE_IS_STRING; + let button0Title = _("uninstall.confirmation.button-0.label"); + let button1Title = _("uninstall.confirmation.button-1.label"); + let response = promptService.confirmEx( + null, + title, + message, + buttonFlags, + button0Title, + button1Title, + null, + null, + { value: 0 } + ); + if (response == 1) { + throw new ExtensionError("User cancelled uninstall of extension"); + } + } + let addon = await AddonManager.getAddonByID(extension.id); + let canUninstall = Boolean( + addon.permissions & AddonManager.PERM_CAN_UNINSTALL + ); + if (!canUninstall) { + throw new ExtensionError("The add-on cannot be uninstalled"); + } + addon.uninstall(); + }, + + async setEnabled(id, enabled) { + let addon = await AddonManager.getAddonByID(id); + if (!addon) { + throw new ExtensionError(`No such addon ${id}`); + } + if (addon.type !== "theme") { + throw new ExtensionError("setEnabled applies only to theme addons"); + } + if (addon.isSystem) { + throw new ExtensionError( + "setEnabled cannot be used with a system addon" + ); + } + if (enabled) { + await addon.enable(); + } else { + await addon.disable(); + } + }, + + onDisabled: new EventManager({ + context, + module: "management", + event: "onDisabled", + extensionApi: this, + }).api(), + + onEnabled: new EventManager({ + context, + module: "management", + event: "onEnabled", + extensionApi: this, + }).api(), + + onInstalled: new EventManager({ + context, + module: "management", + event: "onInstalled", + extensionApi: this, + }).api(), + + onUninstalled: new EventManager({ + context, + module: "management", + event: "onUninstalled", + extensionApi: this, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-networkStatus.js b/toolkit/components/extensions/parent/ext-networkStatus.js new file mode 100644 index 0000000000..7379d746f5 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-networkStatus.js @@ -0,0 +1,85 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "gNetworkLinkService", + "@mozilla.org/network/network-link-service;1", + "nsINetworkLinkService" +); + +function getLinkType() { + switch (gNetworkLinkService.linkType) { + case gNetworkLinkService.LINK_TYPE_UNKNOWN: + return "unknown"; + case gNetworkLinkService.LINK_TYPE_ETHERNET: + return "ethernet"; + case gNetworkLinkService.LINK_TYPE_USB: + return "usb"; + case gNetworkLinkService.LINK_TYPE_WIFI: + return "wifi"; + case gNetworkLinkService.LINK_TYPE_WIMAX: + return "wimax"; + case gNetworkLinkService.LINK_TYPE_MOBILE: + return "mobile"; + default: + return "unknown"; + } +} + +function getLinkStatus() { + if (!gNetworkLinkService.linkStatusKnown) { + return "unknown"; + } + return gNetworkLinkService.isLinkUp ? "up" : "down"; +} + +function getLinkInfo() { + return { + id: gNetworkLinkService.networkID || undefined, + status: getLinkStatus(), + type: getLinkType(), + }; +} + +this.networkStatus = class extends ExtensionAPI { + getAPI(context) { + return { + networkStatus: { + getLinkInfo, + onConnectionChanged: new EventManager({ + context, + name: "networkStatus.onConnectionChanged", + register: fire => { + let observerStatus = (subject, topic, data) => { + fire.async(getLinkInfo()); + }; + + Services.obs.addObserver( + observerStatus, + "network:link-status-changed" + ); + Services.obs.addObserver( + observerStatus, + "network:link-type-changed" + ); + return () => { + Services.obs.removeObserver( + observerStatus, + "network:link-status-changed" + ); + Services.obs.removeObserver( + observerStatus, + "network:link-type-changed" + ); + }; + }, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-notifications.js b/toolkit/components/extensions/parent/ext-notifications.js new file mode 100644 index 0000000000..5b42e6c936 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-notifications.js @@ -0,0 +1,188 @@ +/* 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"; + +const ToolkitModules = {}; + +ChromeUtils.defineESModuleGetters(ToolkitModules, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", +}); + +var { ignoreEvent } = ExtensionCommon; + +// Manages a notification popup (notifications API) created by the extension. +function Notification(context, notificationsMap, id, options) { + this.notificationsMap = notificationsMap; + this.id = id; + this.options = options; + + let imageURL; + if (options.iconUrl) { + imageURL = context.extension.baseURI.resolve(options.iconUrl); + } + + // Set before calling into nsIAlertsService, because the notification may be + // closed during the call. + notificationsMap.set(id, this); + + try { + let svc = Cc["@mozilla.org/alerts-service;1"].getService( + Ci.nsIAlertsService + ); + svc.showAlertNotification( + imageURL, + options.title, + options.message, + true, // textClickable + this.id, + this, + this.id, + undefined, + undefined, + undefined, + // Principal is not set because doing so reveals buttons to control + // notification preferences, which are currently not implemented for + // notifications triggered via this extension API (bug 1589693). + undefined, + context.incognito + ); + } catch (e) { + // This will fail if alerts aren't available on the system. + + this.observe(null, "alertfinished", id); + } +} + +Notification.prototype = { + clear() { + try { + let svc = Cc["@mozilla.org/alerts-service;1"].getService( + Ci.nsIAlertsService + ); + svc.closeAlert(this.id); + } catch (e) { + // This will fail if the OS doesn't support this function. + } + this.notificationsMap.delete(this.id); + }, + + observe(subject, topic, data) { + switch (topic) { + case "alertclickcallback": + this.notificationsMap.emit("clicked", data); + break; + case "alertfinished": + this.notificationsMap.emit("closed", data); + this.notificationsMap.delete(this.id); + break; + case "alertshow": + this.notificationsMap.emit("shown", data); + break; + } + }, +}; + +this.notifications = class extends ExtensionAPI { + constructor(extension) { + super(extension); + + this.nextId = 0; + this.notificationsMap = new Map(); + ToolkitModules.EventEmitter.decorate(this.notificationsMap); + } + + onShutdown() { + for (let notification of this.notificationsMap.values()) { + notification.clear(); + } + } + + getAPI(context) { + let notificationsMap = this.notificationsMap; + + return { + notifications: { + create: (notificationId, options) => { + if (!notificationId) { + notificationId = String(this.nextId++); + } + + if (notificationsMap.has(notificationId)) { + notificationsMap.get(notificationId).clear(); + } + + new Notification(context, notificationsMap, notificationId, options); + + return Promise.resolve(notificationId); + }, + + clear: function (notificationId) { + if (notificationsMap.has(notificationId)) { + notificationsMap.get(notificationId).clear(); + return Promise.resolve(true); + } + return Promise.resolve(false); + }, + + getAll: function () { + let result = {}; + notificationsMap.forEach((value, key) => { + result[key] = value.options; + }); + return Promise.resolve(result); + }, + + onClosed: new EventManager({ + context, + name: "notifications.onClosed", + register: fire => { + let listener = (event, notificationId) => { + // TODO Bug 1413188, Support the byUser argument. + fire.async(notificationId, true); + }; + + notificationsMap.on("closed", listener); + return () => { + notificationsMap.off("closed", listener); + }; + }, + }).api(), + + onClicked: new EventManager({ + context, + name: "notifications.onClicked", + register: fire => { + let listener = (event, notificationId) => { + fire.async(notificationId); + }; + + notificationsMap.on("clicked", listener); + return () => { + notificationsMap.off("clicked", listener); + }; + }, + }).api(), + + onShown: new EventManager({ + context, + name: "notifications.onShown", + register: fire => { + let listener = (event, notificationId) => { + fire.async(notificationId); + }; + + notificationsMap.on("shown", listener); + return () => { + notificationsMap.off("shown", listener); + }; + }, + }).api(), + + // TODO Bug 1190681, implement button support. + onButtonClicked: ignoreEvent(context, "notifications.onButtonClicked"), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-permissions.js b/toolkit/components/extensions/parent/ext-permissions.js new file mode 100644 index 0000000000..8639381de7 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-permissions.js @@ -0,0 +1,191 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs", +}); + +var { ExtensionError } = ExtensionUtils; + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "promptsEnabled", + "extensions.webextOptionalPermissionPrompts" +); + +function normalizePermissions(perms) { + perms = { ...perms }; + perms.permissions = perms.permissions.filter( + perm => !perm.startsWith("internal:") + ); + return perms; +} + +this.permissions = class extends ExtensionAPIPersistent { + PERSISTENT_EVENTS = { + onAdded({ fire }) { + let { extension } = this; + let callback = (event, change) => { + if (change.extensionId == extension.id && change.added) { + let perms = normalizePermissions(change.added); + if (perms.permissions.length || perms.origins.length) { + fire.async(perms); + } + } + }; + + extensions.on("change-permissions", callback); + return { + unregister() { + extensions.off("change-permissions", callback); + }, + convert(_fire) { + fire = _fire; + }, + }; + }, + onRemoved({ fire }) { + let { extension } = this; + let callback = (event, change) => { + if (change.extensionId == extension.id && change.removed) { + let perms = normalizePermissions(change.removed); + if (perms.permissions.length || perms.origins.length) { + fire.async(perms); + } + } + }; + + extensions.on("change-permissions", callback); + return { + unregister() { + extensions.off("change-permissions", callback); + }, + convert(_fire) { + fire = _fire; + }, + }; + }, + }; + + getAPI(context) { + let { extension } = context; + + return { + permissions: { + async request(perms) { + let { permissions, origins } = perms; + + let { optionalPermissions } = context.extension; + for (let perm of permissions) { + if (!optionalPermissions.includes(perm)) { + throw new ExtensionError( + `Cannot request permission ${perm} since it was not declared in optional_permissions` + ); + } + } + + let optionalOrigins = context.extension.optionalOrigins; + for (let origin of origins) { + if (!optionalOrigins.subsumes(new MatchPattern(origin))) { + throw new ExtensionError( + `Cannot request origin permission for ${origin} since it was not declared in the manifest` + ); + } + } + + if (promptsEnabled) { + permissions = permissions.filter( + perm => !context.extension.hasPermission(perm) + ); + origins = origins.filter( + origin => + !context.extension.allowedOrigins.subsumes( + new MatchPattern(origin) + ) + ); + + if (!permissions.length && !origins.length) { + return true; + } + + let browser = context.pendingEventBrowser || context.xulBrowser; + let allow = await new Promise(resolve => { + let subject = { + wrappedJSObject: { + browser, + name: context.extension.name, + id: context.extension.id, + icon: context.extension.getPreferredIcon(32), + permissions: { permissions, origins }, + resolve, + }, + }; + Services.obs.notifyObservers( + subject, + "webextension-optional-permission-prompt" + ); + }); + if (!allow) { + return false; + } + } + + await ExtensionPermissions.add(extension.id, perms, extension); + return true; + }, + + async getAll() { + let perms = normalizePermissions(context.extension.activePermissions); + delete perms.apis; + return perms; + }, + + async contains(permissions) { + for (let perm of permissions.permissions) { + if (!context.extension.hasPermission(perm)) { + return false; + } + } + + for (let origin of permissions.origins) { + if ( + !context.extension.allowedOrigins.subsumes( + new MatchPattern(origin) + ) + ) { + return false; + } + } + + return true; + }, + + async remove(permissions) { + await ExtensionPermissions.remove( + extension.id, + permissions, + extension + ); + return true; + }, + + onAdded: new EventManager({ + context, + module: "permissions", + event: "onAdded", + extensionApi: this, + }).api(), + + onRemoved: new EventManager({ + context, + module: "permissions", + event: "onRemoved", + extensionApi: this, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-privacy.js b/toolkit/components/extensions/parent/ext-privacy.js new file mode 100644 index 0000000000..1c4bf05ff1 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-privacy.js @@ -0,0 +1,516 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +var { ExtensionPreferencesManager } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPreferencesManager.sys.mjs" +); + +var { ExtensionError } = ExtensionUtils; +var { getSettingsAPI, getPrimedSettingsListener } = ExtensionPreferencesManager; + +const cookieSvc = Ci.nsICookieService; + +const getIntPref = p => Services.prefs.getIntPref(p, undefined); +const getBoolPref = p => Services.prefs.getBoolPref(p, undefined); + +const TLS_MIN_PREF = "security.tls.version.min"; +const TLS_MAX_PREF = "security.tls.version.max"; + +const cookieBehaviorValues = new Map([ + ["allow_all", cookieSvc.BEHAVIOR_ACCEPT], + ["reject_third_party", cookieSvc.BEHAVIOR_REJECT_FOREIGN], + ["reject_all", cookieSvc.BEHAVIOR_REJECT], + ["allow_visited", cookieSvc.BEHAVIOR_LIMIT_FOREIGN], + ["reject_trackers", cookieSvc.BEHAVIOR_REJECT_TRACKER], + [ + "reject_trackers_and_partition_foreign", + cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN, + ], +]); + +function isTLSMinVersionLowerOrEQThan(version) { + return ( + Services.prefs.getDefaultBranch("").getIntPref(TLS_MIN_PREF) <= version + ); +} + +const TLS_VERSIONS = [ + { version: 1, name: "TLSv1", settable: isTLSMinVersionLowerOrEQThan(1) }, + { version: 2, name: "TLSv1.1", settable: isTLSMinVersionLowerOrEQThan(2) }, + { version: 3, name: "TLSv1.2", settable: true }, + { version: 4, name: "TLSv1.3", settable: true }, +]; + +// Add settings objects for supported APIs to the preferences manager. +ExtensionPreferencesManager.addSetting("network.networkPredictionEnabled", { + permission: "privacy", + prefNames: [ + "network.predictor.enabled", + "network.prefetch-next", + "network.http.speculative-parallel-limit", + "network.dns.disablePrefetch", + ], + + setCallback(value) { + return { + "network.http.speculative-parallel-limit": value ? undefined : 0, + "network.dns.disablePrefetch": !value, + "network.predictor.enabled": value, + "network.prefetch-next": value, + }; + }, + + getCallback() { + return ( + getBoolPref("network.predictor.enabled") && + getBoolPref("network.prefetch-next") && + getIntPref("network.http.speculative-parallel-limit") > 0 && + !getBoolPref("network.dns.disablePrefetch") + ); + }, +}); + +ExtensionPreferencesManager.addSetting("network.globalPrivacyControl", { + permission: "privacy", + prefNames: ["privacy.globalprivacycontrol.enabled"], + readOnly: true, + + setCallback(value) { + return { + "privacy.globalprivacycontrol.enabled": value, + }; + }, + + getCallback() { + return getBoolPref("privacy.globalprivacycontrol.enabled"); + }, +}); + +ExtensionPreferencesManager.addSetting("network.httpsOnlyMode", { + permission: "privacy", + prefNames: [ + "dom.security.https_only_mode", + "dom.security.https_only_mode_pbm", + ], + readOnly: true, + + setCallback(value) { + let prefs = { + "dom.security.https_only_mode": false, + "dom.security.https_only_mode_pbm": false, + }; + + switch (value) { + case "always": + prefs["dom.security.https_only_mode"] = true; + break; + + case "private_browsing": + prefs["dom.security.https_only_mode_pbm"] = true; + break; + + case "never": + break; + } + + return prefs; + }, + + getCallback() { + if (getBoolPref("dom.security.https_only_mode")) { + return "always"; + } + if (getBoolPref("dom.security.https_only_mode_pbm")) { + return "private_browsing"; + } + return "never"; + }, +}); + +ExtensionPreferencesManager.addSetting("network.peerConnectionEnabled", { + permission: "privacy", + prefNames: ["media.peerconnection.enabled"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return getBoolPref("media.peerconnection.enabled"); + }, +}); + +ExtensionPreferencesManager.addSetting("network.webRTCIPHandlingPolicy", { + permission: "privacy", + prefNames: [ + "media.peerconnection.ice.default_address_only", + "media.peerconnection.ice.no_host", + "media.peerconnection.ice.proxy_only_if_behind_proxy", + "media.peerconnection.ice.proxy_only", + ], + + setCallback(value) { + let prefs = {}; + switch (value) { + case "default": + // All prefs are already set to be reset. + break; + + case "default_public_and_private_interfaces": + prefs["media.peerconnection.ice.default_address_only"] = true; + break; + + case "default_public_interface_only": + prefs["media.peerconnection.ice.default_address_only"] = true; + prefs["media.peerconnection.ice.no_host"] = true; + break; + + case "disable_non_proxied_udp": + prefs["media.peerconnection.ice.default_address_only"] = true; + prefs["media.peerconnection.ice.no_host"] = true; + prefs["media.peerconnection.ice.proxy_only_if_behind_proxy"] = true; + break; + + case "proxy_only": + prefs["media.peerconnection.ice.proxy_only"] = true; + break; + } + return prefs; + }, + + getCallback() { + if (getBoolPref("media.peerconnection.ice.proxy_only")) { + return "proxy_only"; + } + + let default_address_only = getBoolPref( + "media.peerconnection.ice.default_address_only" + ); + if (default_address_only) { + let no_host = getBoolPref("media.peerconnection.ice.no_host"); + if (no_host) { + if ( + getBoolPref("media.peerconnection.ice.proxy_only_if_behind_proxy") + ) { + return "disable_non_proxied_udp"; + } + return "default_public_interface_only"; + } + return "default_public_and_private_interfaces"; + } + + return "default"; + }, +}); + +ExtensionPreferencesManager.addSetting("services.passwordSavingEnabled", { + permission: "privacy", + prefNames: ["signon.rememberSignons"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return getBoolPref("signon.rememberSignons"); + }, +}); + +ExtensionPreferencesManager.addSetting("websites.cookieConfig", { + permission: "privacy", + prefNames: ["network.cookie.cookieBehavior"], + + setCallback(value) { + const cookieBehavior = cookieBehaviorValues.get(value.behavior); + + // Intentionally use Preferences.get("network.cookie.cookieBehavior") here + // to read the "real" preference value. + const needUpdate = + cookieBehavior !== getIntPref("network.cookie.cookieBehavior"); + if ( + needUpdate && + getBoolPref("privacy.firstparty.isolate") && + cookieBehavior === cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN + ) { + throw new ExtensionError( + `Invalid cookieConfig '${value.behavior}' when firstPartyIsolate is enabled` + ); + } + + if (typeof value.nonPersistentCookies === "boolean") { + Cu.reportError( + "'nonPersistentCookies' has been deprecated and it has no effect anymore." + ); + } + + return { + "network.cookie.cookieBehavior": cookieBehavior, + }; + }, + + getCallback() { + let prefValue = getIntPref("network.cookie.cookieBehavior"); + return { + behavior: Array.from(cookieBehaviorValues.entries()).find( + entry => entry[1] === prefValue + )[0], + // Bug 1754924 - this property is now deprecated. + nonPersistentCookies: false, + }; + }, +}); + +ExtensionPreferencesManager.addSetting("websites.firstPartyIsolate", { + permission: "privacy", + prefNames: ["privacy.firstparty.isolate"], + + setCallback(value) { + // Intentionally use Preferences.get("network.cookie.cookieBehavior") here + // to read the "real" preference value. + const cookieBehavior = getIntPref("network.cookie.cookieBehavior"); + + const needUpdate = value !== getBoolPref("privacy.firstparty.isolate"); + if ( + needUpdate && + value && + cookieBehavior === cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN + ) { + const behavior = Array.from(cookieBehaviorValues.entries()).find( + entry => entry[1] === cookieBehavior + )[0]; + throw new ExtensionError( + `Can't enable firstPartyIsolate when cookieBehavior is '${behavior}'` + ); + } + + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return getBoolPref("privacy.firstparty.isolate"); + }, +}); + +ExtensionPreferencesManager.addSetting("websites.hyperlinkAuditingEnabled", { + permission: "privacy", + prefNames: ["browser.send_pings"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return getBoolPref("browser.send_pings"); + }, +}); + +ExtensionPreferencesManager.addSetting("websites.referrersEnabled", { + permission: "privacy", + prefNames: ["network.http.sendRefererHeader"], + + // Values for network.http.sendRefererHeader: + // 0=don't send any, 1=send only on clicks, 2=send on image requests as well + // http://searchfox.org/mozilla-central/rev/61054508641ee76f9c49bcf7303ef3cfb6b410d2/modules/libpref/init/all.js#1585 + setCallback(value) { + return { [this.prefNames[0]]: value ? 2 : 0 }; + }, + + getCallback() { + return getIntPref("network.http.sendRefererHeader") !== 0; + }, +}); + +ExtensionPreferencesManager.addSetting("websites.resistFingerprinting", { + permission: "privacy", + prefNames: ["privacy.resistFingerprinting"], + + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + + getCallback() { + return getBoolPref("privacy.resistFingerprinting"); + }, +}); + +ExtensionPreferencesManager.addSetting("websites.trackingProtectionMode", { + permission: "privacy", + prefNames: [ + "privacy.trackingprotection.enabled", + "privacy.trackingprotection.pbmode.enabled", + ], + + setCallback(value) { + // Default to private browsing. + let prefs = { + "privacy.trackingprotection.enabled": false, + "privacy.trackingprotection.pbmode.enabled": true, + }; + + switch (value) { + case "private_browsing": + break; + + case "always": + prefs["privacy.trackingprotection.enabled"] = true; + break; + + case "never": + prefs["privacy.trackingprotection.pbmode.enabled"] = false; + break; + } + + return prefs; + }, + + getCallback() { + if (getBoolPref("privacy.trackingprotection.enabled")) { + return "always"; + } else if (getBoolPref("privacy.trackingprotection.pbmode.enabled")) { + return "private_browsing"; + } + return "never"; + }, +}); + +ExtensionPreferencesManager.addSetting("network.tlsVersionRestriction", { + permission: "privacy", + prefNames: [TLS_MIN_PREF, TLS_MAX_PREF], + + setCallback(value) { + function tlsStringToVersion(string) { + const version = TLS_VERSIONS.find(a => a.name === string); + if (version && version.settable) { + return version.version; + } + + throw new ExtensionError( + `Setting TLS version ${string} is not allowed for security reasons.` + ); + } + + const prefs = {}; + + if (value.minimum) { + prefs[TLS_MIN_PREF] = tlsStringToVersion(value.minimum); + } + + if (value.maximum) { + prefs[TLS_MAX_PREF] = tlsStringToVersion(value.maximum); + } + + // If minimum has passed and it's greater than the max value. + if (prefs[TLS_MIN_PREF]) { + const max = prefs[TLS_MAX_PREF] || getIntPref(TLS_MAX_PREF); + if (max < prefs[TLS_MIN_PREF]) { + throw new ExtensionError( + `Setting TLS min version grater than the max version is not allowed.` + ); + } + } + + // If maximum has passed and it's lower than the min value. + else if (prefs[TLS_MAX_PREF]) { + const min = getIntPref(TLS_MIN_PREF); + if (min > prefs[TLS_MAX_PREF]) { + throw new ExtensionError( + `Setting TLS max version lower than the min version is not allowed.` + ); + } + } + + return prefs; + }, + + getCallback() { + function tlsVersionToString(pref) { + const value = getIntPref(pref); + const version = TLS_VERSIONS.find(a => a.version === value); + if (version) { + return version.name; + } + return "unknown"; + } + + return { + minimum: tlsVersionToString(TLS_MIN_PREF), + maximum: tlsVersionToString(TLS_MAX_PREF), + }; + }, + + validate(extension) { + if (!extension.isPrivileged) { + throw new ExtensionError( + "tlsVersionRestriction can be set by privileged extensions only." + ); + } + }, +}); + +this.privacy = class extends ExtensionAPI { + primeListener(event, fire) { + let { extension } = this; + let listener = getPrimedSettingsListener({ + extension, + name: event, + }); + return listener(fire); + } + + getAPI(context) { + function makeSettingsAPI(name) { + return getSettingsAPI({ + context, + module: "privacy", + name, + }); + } + + return { + privacy: { + network: { + networkPredictionEnabled: makeSettingsAPI( + "network.networkPredictionEnabled" + ), + globalPrivacyControl: makeSettingsAPI("network.globalPrivacyControl"), + httpsOnlyMode: makeSettingsAPI("network.httpsOnlyMode"), + peerConnectionEnabled: makeSettingsAPI( + "network.peerConnectionEnabled" + ), + webRTCIPHandlingPolicy: makeSettingsAPI( + "network.webRTCIPHandlingPolicy" + ), + tlsVersionRestriction: makeSettingsAPI( + "network.tlsVersionRestriction" + ), + }, + + services: { + passwordSavingEnabled: makeSettingsAPI( + "services.passwordSavingEnabled" + ), + }, + + websites: { + cookieConfig: makeSettingsAPI("websites.cookieConfig"), + firstPartyIsolate: makeSettingsAPI("websites.firstPartyIsolate"), + hyperlinkAuditingEnabled: makeSettingsAPI( + "websites.hyperlinkAuditingEnabled" + ), + referrersEnabled: makeSettingsAPI("websites.referrersEnabled"), + resistFingerprinting: makeSettingsAPI( + "websites.resistFingerprinting" + ), + trackingProtectionMode: makeSettingsAPI( + "websites.trackingProtectionMode" + ), + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-protocolHandlers.js b/toolkit/components/extensions/parent/ext-protocolHandlers.js new file mode 100644 index 0000000000..36cdf25d42 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-protocolHandlers.js @@ -0,0 +1,100 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "handlerService", + "@mozilla.org/uriloader/handler-service;1", + "nsIHandlerService" +); +XPCOMUtils.defineLazyServiceGetter( + this, + "protocolService", + "@mozilla.org/uriloader/external-protocol-service;1", + "nsIExternalProtocolService" +); + +const hasHandlerApp = handlerConfig => { + let protoInfo = protocolService.getProtocolHandlerInfo( + handlerConfig.protocol + ); + let appHandlers = protoInfo.possibleApplicationHandlers; + for (let i = 0; i < appHandlers.length; i++) { + let handler = appHandlers.queryElementAt(i, Ci.nsISupports); + if ( + handler instanceof Ci.nsIWebHandlerApp && + handler.uriTemplate === handlerConfig.uriTemplate + ) { + return true; + } + } + return false; +}; + +this.protocolHandlers = class extends ExtensionAPI { + onManifestEntry(entryName) { + let { extension } = this; + let { manifest } = extension; + + for (let handlerConfig of manifest.protocol_handlers) { + if (hasHandlerApp(handlerConfig)) { + continue; + } + + let handler = Cc[ + "@mozilla.org/uriloader/web-handler-app;1" + ].createInstance(Ci.nsIWebHandlerApp); + handler.name = handlerConfig.name; + handler.uriTemplate = handlerConfig.uriTemplate; + + let protoInfo = protocolService.getProtocolHandlerInfo( + handlerConfig.protocol + ); + let handlers = protoInfo.possibleApplicationHandlers; + if (protoInfo.preferredApplicationHandler || handlers.length) { + protoInfo.alwaysAskBeforeHandling = true; + } else { + protoInfo.preferredApplicationHandler = handler; + protoInfo.alwaysAskBeforeHandling = false; + } + handlers.appendElement(handler); + handlerService.store(protoInfo); + } + } + + onShutdown(isAppShutdown) { + let { extension } = this; + let { manifest } = extension; + + if (isAppShutdown) { + return; + } + + for (let handlerConfig of manifest.protocol_handlers) { + let protoInfo = protocolService.getProtocolHandlerInfo( + handlerConfig.protocol + ); + let appHandlers = protoInfo.possibleApplicationHandlers; + for (let i = 0; i < appHandlers.length; i++) { + let handler = appHandlers.queryElementAt(i, Ci.nsISupports); + if ( + handler instanceof Ci.nsIWebHandlerApp && + handler.uriTemplate === handlerConfig.uriTemplate + ) { + appHandlers.removeElementAt(i); + if (protoInfo.preferredApplicationHandler === handler) { + protoInfo.preferredApplicationHandler = null; + protoInfo.alwaysAskBeforeHandling = true; + } + handlerService.store(protoInfo); + break; + } + } + } + } +}; diff --git a/toolkit/components/extensions/parent/ext-proxy.js b/toolkit/components/extensions/parent/ext-proxy.js new file mode 100644 index 0000000000..86505f9423 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-proxy.js @@ -0,0 +1,335 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + ProxyChannelFilter: "resource://gre/modules/ProxyChannelFilter.sys.mjs", +}); + +// Delayed wakeup is tied to ExtensionParent.browserPaintedPromise, which is +// when the first browser window has been painted. On Android, parts of the +// browser can trigger requests without browser "window" (geckoview.xhtml). +// Therefore we allow such proxy events to trigger wakeup. +// On desktop, we do not wake up early, to minimize the amount of work before +// a browser window is painted. +XPCOMUtils.defineLazyPreferenceGetter( + this, + "isEarlyWakeupOnRequestEnabled", + "extensions.webextensions.early_background_wakeup_on_request", + false +); +var { ExtensionPreferencesManager } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPreferencesManager.sys.mjs" +); + +var { ExtensionError } = ExtensionUtils; +var { getSettingsAPI } = ExtensionPreferencesManager; + +const proxySvc = Ci.nsIProtocolProxyService; + +const PROXY_TYPES_MAP = new Map([ + ["none", proxySvc.PROXYCONFIG_DIRECT], + ["autoDetect", proxySvc.PROXYCONFIG_WPAD], + ["system", proxySvc.PROXYCONFIG_SYSTEM], + ["manual", proxySvc.PROXYCONFIG_MANUAL], + ["autoConfig", proxySvc.PROXYCONFIG_PAC], +]); + +const DEFAULT_PORTS = new Map([ + ["http", 80], + ["ssl", 443], + ["socks", 1080], +]); + +ExtensionPreferencesManager.addSetting("proxy.settings", { + permission: "proxy", + prefNames: [ + "network.proxy.type", + "network.proxy.http", + "network.proxy.http_port", + "network.proxy.share_proxy_settings", + "network.proxy.ssl", + "network.proxy.ssl_port", + "network.proxy.socks", + "network.proxy.socks_port", + "network.proxy.socks_version", + "network.proxy.socks_remote_dns", + "network.proxy.no_proxies_on", + "network.proxy.autoconfig_url", + "signon.autologin.proxy", + "network.http.proxy.respect-be-conservative", + ], + + setCallback(value) { + let prefs = { + "network.proxy.type": PROXY_TYPES_MAP.get(value.proxyType), + "signon.autologin.proxy": value.autoLogin, + "network.proxy.socks_remote_dns": value.proxyDNS, + "network.proxy.autoconfig_url": value.autoConfigUrl, + "network.proxy.share_proxy_settings": value.httpProxyAll, + "network.proxy.socks_version": value.socksVersion, + "network.proxy.no_proxies_on": value.passthrough, + "network.http.proxy.respect-be-conservative": value.respectBeConservative, + }; + + for (let prop of ["http", "ssl", "socks"]) { + if (value[prop]) { + let url = new URL(`http://${value[prop]}`); + prefs[`network.proxy.${prop}`] = url.hostname; + // Only fall back to defaults if no port provided. + let [, rawPort] = value[prop].split(":"); + let port = parseInt(rawPort, 10) || DEFAULT_PORTS.get(prop); + prefs[`network.proxy.${prop}_port`] = port; + } + } + + return prefs; + }, +}); + +function registerProxyFilterEvent( + context, + extension, + fire, + filterProps, + extraInfoSpec = [] +) { + let listener = data => { + if (isEarlyWakeupOnRequestEnabled && fire.wakeup) { + // Starts the background script if it has not started, no-op otherwise. + extension.emit("start-background-script"); + } + return fire.sync(data); + }; + + let filter = { ...filterProps }; + if (filter.urls) { + let perms = new MatchPatternSet([ + ...extension.allowedOrigins.patterns, + ...extension.optionalOrigins.patterns, + ]); + filter.urls = new MatchPatternSet(filter.urls); + + if (!perms.overlapsAll(filter.urls)) { + Cu.reportError( + "The proxy.onRequest filter doesn't overlap with host permissions." + ); + } + } + + let proxyFilter = new ProxyChannelFilter( + context, + extension, + listener, + filter, + extraInfoSpec + ); + return { + unregister: () => { + proxyFilter.destroy(); + }, + convert(_fire, _context) { + fire = _fire; + proxyFilter.context = _context; + }, + }; +} + +this.proxy = class extends ExtensionAPIPersistent { + PERSISTENT_EVENTS = { + onRequest({ fire, context }, params) { + return registerProxyFilterEvent(context, this.extension, fire, ...params); + }, + }; + + getAPI(context) { + let { extension } = context; + let self = this; + + return { + proxy: { + onRequest: new EventManager({ + context, + module: "proxy", + event: "onRequest", + extensionApi: self, + }).api(), + + // Leaving as non-persistent. By itself it's not useful since proxy-error + // is emitted from the proxy filter. + onError: new EventManager({ + context, + name: "proxy.onError", + register: fire => { + let listener = (name, error) => { + fire.async(error); + }; + extension.on("proxy-error", listener); + return () => { + extension.off("proxy-error", listener); + }; + }, + }).api(), + + settings: Object.assign( + getSettingsAPI({ + context, + name: "proxy.settings", + callback() { + let prefValue = Services.prefs.getIntPref("network.proxy.type"); + let proxyConfig = { + proxyType: Array.from(PROXY_TYPES_MAP.entries()).find( + entry => entry[1] === prefValue + )[0], + autoConfigUrl: Services.prefs.getCharPref( + "network.proxy.autoconfig_url" + ), + autoLogin: Services.prefs.getBoolPref("signon.autologin.proxy"), + proxyDNS: Services.prefs.getBoolPref( + "network.proxy.socks_remote_dns" + ), + httpProxyAll: Services.prefs.getBoolPref( + "network.proxy.share_proxy_settings" + ), + socksVersion: Services.prefs.getIntPref( + "network.proxy.socks_version" + ), + passthrough: Services.prefs.getCharPref( + "network.proxy.no_proxies_on" + ), + }; + + if (extension.isPrivileged) { + proxyConfig.respectBeConservative = Services.prefs.getBoolPref( + "network.http.proxy.respect-be-conservative" + ); + } + + for (let prop of ["http", "ssl", "socks"]) { + let host = Services.prefs.getCharPref(`network.proxy.${prop}`); + let port = Services.prefs.getIntPref( + `network.proxy.${prop}_port` + ); + proxyConfig[prop] = port ? `${host}:${port}` : host; + } + + return proxyConfig; + }, + // proxy.settings is unsupported on android. + validate() { + if (AppConstants.platform == "android") { + throw new ExtensionError( + `proxy.settings is not supported on android.` + ); + } + }, + }), + { + set: details => { + if (AppConstants.platform === "android") { + throw new ExtensionError( + "proxy.settings is not supported on android." + ); + } + + if (!extension.privateBrowsingAllowed) { + throw new ExtensionError( + "proxy.settings requires private browsing permission." + ); + } + + if (!Services.policies.isAllowed("changeProxySettings")) { + throw new ExtensionError( + "Proxy settings are being managed by the Policies manager." + ); + } + + let value = details.value; + + // proxyType is optional and it should default to "system" when missing. + if (value.proxyType == null) { + value.proxyType = "system"; + } + + if (!PROXY_TYPES_MAP.has(value.proxyType)) { + throw new ExtensionError( + `${value.proxyType} is not a valid value for proxyType.` + ); + } + + if (value.httpProxyAll) { + // Match what about:preferences does with proxy settings + // since the proxy service does not check the value + // of share_proxy_settings. + value.ssl = value.http; + } + + for (let prop of ["http", "ssl", "socks"]) { + let host = value[prop]; + if (host) { + try { + // Fixup in case a full url is passed. + if (host.includes("://")) { + value[prop] = new URL(host).host; + } else { + // Validate the host value. + new URL(`http://${host}`); + } + } catch (e) { + throw new ExtensionError( + `${value[prop]} is not a valid value for ${prop}.` + ); + } + } + } + + if (value.proxyType === "autoConfig" || value.autoConfigUrl) { + try { + new URL(value.autoConfigUrl); + } catch (e) { + throw new ExtensionError( + `${value.autoConfigUrl} is not a valid value for autoConfigUrl.` + ); + } + } + + if (value.socksVersion !== undefined) { + if ( + !Number.isInteger(value.socksVersion) || + value.socksVersion < 4 || + value.socksVersion > 5 + ) { + throw new ExtensionError( + `${value.socksVersion} is not a valid value for socksVersion.` + ); + } + } + + if ( + value.respectBeConservative !== undefined && + !extension.isPrivileged && + Services.prefs.getBoolPref( + "network.http.proxy.respect-be-conservative" + ) != value.respectBeConservative + ) { + throw new ExtensionError( + `respectBeConservative can be set by privileged extensions only.` + ); + } + + return ExtensionPreferencesManager.setSetting( + extension.id, + "proxy.settings", + value + ); + }, + } + ), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-runtime.js b/toolkit/components/extensions/parent/ext-runtime.js new file mode 100644 index 0000000000..f4f9ea6616 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-runtime.js @@ -0,0 +1,310 @@ +/* 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"; + +// This file expects tabTracker to be defined in the global scope (e.g. +// by ext-browser.js or ext-android.js). +/* global tabTracker */ + +var { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs", + DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "gRuntimeTimeout", + "extensions.webextensions.runtime.timeout", + 5000 +); + +this.runtime = class extends ExtensionAPIPersistent { + PERSISTENT_EVENTS = { + // Despite not being part of PERSISTENT_EVENTS, the following events are + // still triggered (after waking up the background context if needed): + // - runtime.onConnect + // - runtime.onConnectExternal + // - runtime.onMessage + // - runtime.onMessageExternal + // For details, see bug 1852317 and test_ext_eventpage_messaging_wakeup.js. + + onInstalled({ fire }) { + let { extension } = this; + let temporary = !!extension.addonData.temporarilyInstalled; + + let listener = () => { + switch (extension.startupReason) { + case "APP_STARTUP": + if (AddonManagerPrivate.browserUpdated) { + fire.sync({ reason: "browser_update", temporary }); + } + break; + case "ADDON_INSTALL": + fire.sync({ reason: "install", temporary }); + break; + case "ADDON_UPGRADE": + fire.sync({ + reason: "update", + previousVersion: extension.addonData.oldVersion, + temporary, + }); + break; + } + }; + extension.on("background-first-run", listener); + return { + unregister() { + extension.off("background-first-run", listener); + }, + convert(_fire) { + fire = _fire; + }, + }; + }, + onUpdateAvailable({ fire }) { + let { extension } = this; + let instanceID = extension.addonData.instanceID; + AddonManager.addUpgradeListener(instanceID, upgrade => { + extension.upgrade = upgrade; + let details = { + version: upgrade.version, + }; + fire.sync(details); + }); + return { + unregister() { + AddonManager.removeUpgradeListener(instanceID); + }, + convert(_fire) { + fire = _fire; + }, + }; + }, + onPerformanceWarning({ fire }) { + let { extension } = this; + + let observer = (subject, topic) => { + let report = subject.QueryInterface(Ci.nsIHangReport); + + if (report?.addonId !== extension.id) { + return; + } + + const performanceWarningEventDetails = { + category: "content_script", + severity: "high", + description: + "Slow extension content script caused a page hang, user was warned.", + }; + + let scriptBrowser = report.scriptBrowser; + let nativeTab = + scriptBrowser?.ownerGlobal.gBrowser?.getTabForBrowser(scriptBrowser); + if (nativeTab) { + performanceWarningEventDetails.tabId = tabTracker.getId(nativeTab); + } + + fire.async(performanceWarningEventDetails); + }; + + Services.obs.addObserver(observer, "process-hang-report"); + return { + unregister: () => { + Services.obs.removeObserver(observer, "process-hang-report"); + }, + convert(_fire, context) { + fire = _fire; + }, + }; + }, + }; + + getAPI(context) { + let { extension } = context; + return { + runtime: { + // onStartup is special-cased in ext-backgroundPages to cause + // an immediate startup. We do not prime onStartup. + onStartup: new EventManager({ + context, + module: "runtime", + event: "onStartup", + register: fire => { + if (context.incognito || extension.startupReason != "APP_STARTUP") { + // This event should not fire if we are operating in a private profile. + return () => {}; + } + let listener = () => { + return fire.sync(); + }; + + extension.on("background-first-run", listener); + + return () => { + extension.off("background-first-run", listener); + }; + }, + }).api(), + + onInstalled: new EventManager({ + context, + module: "runtime", + event: "onInstalled", + extensionApi: this, + }).api(), + + onUpdateAvailable: new EventManager({ + context, + module: "runtime", + event: "onUpdateAvailable", + extensionApi: this, + }).api(), + + onSuspend: new EventManager({ + context, + name: "runtime.onSuspend", + resetIdleOnEvent: false, + register: fire => { + let listener = async () => { + let timedOut = false; + async function promiseFire() { + try { + await fire.async(); + } catch (e) {} + } + await Promise.race([ + promiseFire(), + ExtensionUtils.promiseTimeout(gRuntimeTimeout).then(() => { + timedOut = true; + }), + ]); + if (timedOut) { + Cu.reportError( + `runtime.onSuspend in ${extension.id} took too long` + ); + } + }; + extension.on("background-script-suspend", listener); + return () => { + extension.off("background-script-suspend", listener); + }; + }, + }).api(), + + onSuspendCanceled: new EventManager({ + context, + name: "runtime.onSuspendCanceled", + register: fire => { + let listener = () => { + fire.async(); + }; + extension.on("background-script-suspend-canceled", listener); + return () => { + extension.off("background-script-suspend-canceled", listener); + }; + }, + }).api(), + + onPerformanceWarning: new EventManager({ + context, + module: "runtime", + event: "onPerformanceWarning", + extensionApi: this, + }).api(), + + reload: async () => { + if (extension.upgrade) { + // If there is a pending update, install it now. + extension.upgrade.install(); + } else { + // Otherwise, reload the current extension. + let addon = await AddonManager.getAddonByID(extension.id); + addon.reload(); + } + }, + + get lastError() { + // TODO(robwu): Figure out how to make sure that errors in the parent + // process are propagated to the child process. + // lastError should not be accessed from the parent. + return context.lastError; + }, + + getBrowserInfo: function () { + const { name, vendor, version, appBuildID } = Services.appinfo; + const info = { name, vendor, version, buildID: appBuildID }; + return Promise.resolve(info); + }, + + getPlatformInfo: function () { + return Promise.resolve(ExtensionParent.PlatformInfo); + }, + + openOptionsPage: function () { + if (!extension.manifest.options_ui) { + return Promise.reject({ message: "No `options_ui` declared" }); + } + + // This expects openOptionsPage to be defined in the file using this, + // e.g. the browser/ version of ext-runtime.js + /* global openOptionsPage:false */ + return openOptionsPage(extension).then(() => {}); + }, + + setUninstallURL: function (url) { + if (url === null || url.length === 0) { + extension.uninstallURL = null; + return Promise.resolve(); + } + + let uri; + try { + uri = new URL(url); + } catch (e) { + return Promise.reject({ + message: `Invalid URL: ${JSON.stringify(url)}`, + }); + } + + if (uri.protocol != "http:" && uri.protocol != "https:") { + return Promise.reject({ + message: "url must have the scheme http or https", + }); + } + + extension.uninstallURL = url; + return Promise.resolve(); + }, + + // This function is not exposed to the extension js code and it is only + // used by the alert function redefined into the background pages to be + // able to open the BrowserConsole from the main process. + openBrowserConsole() { + if (AppConstants.platform !== "android") { + DevToolsShim.openBrowserConsole(); + } + }, + + async internalWakeupBackground() { + const { background } = extension.manifest; + if ( + background && + (background.page || background.scripts) && + // Note: if background.service_worker is specified, it takes + // precedence over page/scripts, and persistentBackground is false. + !extension.persistentBackground + ) { + await extension.wakeupBackground(); + } + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-scripting.js b/toolkit/components/extensions/parent/ext-scripting.js new file mode 100644 index 0000000000..baa05f3aad --- /dev/null +++ b/toolkit/components/extensions/parent/ext-scripting.js @@ -0,0 +1,365 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +const { + ExtensionScriptingStore, + makeInternalContentScript, + makePublicContentScript, +} = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionScriptingStore.sys.mjs" +); + +var { ExtensionError, parseMatchPatterns } = ExtensionUtils; + +// Map> - For each extension, we keep a map +// where the key is a user-provided script ID, the value is an internal +// generated integer. +const gScriptIdsMap = new Map(); + +/** + * Inserts a script or style in the given tab, and returns a promise which + * resolves when the operation has completed. + * + * @param {BaseContext} context + * The extension context for which to perform the injection. + * @param {object} details + * The details object, specifying what to inject, where, and when. + * Derived from the ScriptInjection or CSSInjection types. + * @param {string} kind + * The kind of data being injected. Possible choices: "js" or "css". + * @param {string} method + * The name of the method which was called to trigger the injection. + * Used to generate appropriate error messages on failure. + * + * @returns {Promise} + * Resolves to the result of the execution, once it has completed. + */ +const execute = (context, details, kind, method) => { + const { tabManager } = context.extension; + + let options = { + jsPaths: [], + cssPaths: [], + removeCSS: method == "removeCSS", + extensionId: context.extension.id, + }; + + const { tabId, frameIds, allFrames } = details.target; + const tab = tabManager.get(tabId); + + options.hasActiveTabPermission = tab.hasActiveTabPermission; + options.matches = tab.extension.allowedOrigins.patterns.map( + host => host.pattern + ); + + const codeKey = kind === "js" ? "func" : "css"; + if ((details.files === null) == (details[codeKey] === null)) { + throw new ExtensionError( + `Exactly one of files and ${codeKey} must be specified.` + ); + } + + if (details[codeKey]) { + options[`${kind}Code`] = details[codeKey]; + } + + if (details.files) { + for (const file of details.files) { + let url = context.uri.resolve(file); + if (!tab.extension.isExtensionURL(url)) { + throw new ExtensionError( + "Files to be injected must be within the extension" + ); + } + options[`${kind}Paths`].push(url); + } + } + + if (allFrames && frameIds) { + throw new ExtensionError("Cannot specify both 'allFrames' and 'frameIds'."); + } + + if (allFrames) { + options.allFrames = allFrames; + } else if (frameIds) { + options.frameIds = frameIds; + } else { + options.frameIds = [0]; + } + + options.runAt = details.injectImmediately + ? "document_start" + : "document_idle"; + options.matchAboutBlank = true; + options.wantReturnValue = true; + // With this option set to `true`, we'll receive executeScript() results with + // `frameId/result` properties and an `error` property will also be returned + // in case of an error. + options.returnResultsWithFrameIds = kind === "js"; + + if (details.origin) { + options.cssOrigin = details.origin.toLowerCase(); + } else { + options.cssOrigin = "author"; + } + + // There is no need to execute anything when we have an empty list of frame + // IDs because (1) it isn't invalid and (2) nothing will get executed. + if (options.frameIds && options.frameIds.length === 0) { + return []; + } + + // This function is derived from `_execute()` in `parent/ext-tabs-base.js`, + // make sure to keep both in sync when relevant. + return tab.queryContent("Execute", options); +}; + +const ensureValidScriptId = id => { + if (!id.length || id.startsWith("_")) { + throw new ExtensionError("Invalid content script id."); + } +}; + +const ensureValidScriptParams = (extension, script) => { + if (!script.js?.length && !script.css?.length) { + throw new ExtensionError("At least one js or css must be specified."); + } + + if (!script.matches?.length) { + throw new ExtensionError("matches must be specified."); + } + + // This will throw if a match pattern is invalid. + parseMatchPatterns(script.matches, { + // This only works with MV2, not MV3. See Bug 1780507 for more information. + restrictSchemes: extension.restrictSchemes, + }); + + if (script.excludeMatches) { + // This will throw if a match pattern is invalid. + parseMatchPatterns(script.excludeMatches, { + // This only works with MV2, not MV3. See Bug 1780507 for more information. + restrictSchemes: extension.restrictSchemes, + }); + } +}; + +this.scripting = class extends ExtensionAPI { + constructor(extension) { + super(extension); + + // We initialize the scriptIdsMap for the extension with the scriptIds of + // the store because this store initializes the extension before we + // construct the scripting API here (and we need those IDs for some of the + // API methods below). + gScriptIdsMap.set( + extension, + ExtensionScriptingStore.getInitialScriptIdsMap(extension) + ); + } + + onShutdown() { + // When the extension is unloaded, the following happens: + // + // 1. The shared memory is cleared in the parent, see [1] + // 2. The policy is marked as invalid, see [2] + // + // The following are not explicitly cleaned up: + // + // - `extension.registeredContentScripts + // - `ExtensionProcessScript.registeredContentScripts` + + // `policy.contentScripts` (via `policy.unregisterContentScripts`) + // + // This means the script won't run again, but there is still potential for + // memory leaks if there is a reference to `extension` or `policy` + // somewhere. + // + // [1]: https://searchfox.org/mozilla-central/rev/211649f071259c4c733b4cafa94c44481c5caacc/toolkit/components/extensions/Extension.jsm#2974-2976 + // [2]: https://searchfox.org/mozilla-central/rev/211649f071259c4c733b4cafa94c44481c5caacc/toolkit/components/extensions/ExtensionProcessScript.jsm#239 + + gScriptIdsMap.delete(this.extension); + } + + getAPI(context) { + const { extension } = context; + + return { + scripting: { + executeScriptInternal: async details => { + return execute(context, details, "js", "executeScript"); + }, + + insertCSS: async details => { + return execute(context, details, "css", "insertCSS").then(() => {}); + }, + + removeCSS: async details => { + return execute(context, details, "css", "removeCSS").then(() => {}); + }, + + registerContentScripts: async scripts => { + // Map + const scriptIdsMap = gScriptIdsMap.get(extension); + // Map + const scriptsToRegister = new Map(); + + for (const script of scripts) { + ensureValidScriptId(script.id); + + if (scriptIdsMap.has(script.id)) { + throw new ExtensionError( + `Content script with id "${script.id}" is already registered.` + ); + } + + if (scriptsToRegister.has(script.id)) { + throw new ExtensionError( + `Script ID "${script.id}" found more than once in 'scripts' array.` + ); + } + + ensureValidScriptParams(extension, script); + + scriptsToRegister.set( + script.id, + makeInternalContentScript(extension, script) + ); + } + + for (const [id, { scriptId, options }] of scriptsToRegister) { + scriptIdsMap.set(id, scriptId); + extension.registeredContentScripts.set(scriptId, options); + } + extension.updateContentScripts(); + + ExtensionScriptingStore.persistAll(extension); + + await extension.broadcast("Extension:RegisterContentScripts", { + id: extension.id, + scripts: Array.from(scriptsToRegister.values()), + }); + }, + + getRegisteredContentScripts: async details => { + // Map + const scriptIdsMap = gScriptIdsMap.get(extension); + + return Array.from(scriptIdsMap.entries()) + .filter( + ([id, scriptId]) => !details?.ids || details.ids.includes(id) + ) + .map(([id, scriptId]) => { + const options = extension.registeredContentScripts.get(scriptId); + + return makePublicContentScript(extension, options); + }); + }, + + unregisterContentScripts: async details => { + // Map + const scriptIdsMap = gScriptIdsMap.get(extension); + + let ids = []; + + if (details?.ids) { + for (const id of details.ids) { + ensureValidScriptId(id); + + if (!scriptIdsMap.has(id)) { + throw new ExtensionError( + `Content script with id "${id}" does not exist.` + ); + } + } + + ids = details.ids; + } else { + ids = Array.from(scriptIdsMap.keys()); + } + + if (ids.length === 0) { + return; + } + + const scriptIds = []; + for (const id of ids) { + const scriptId = scriptIdsMap.get(id); + + extension.registeredContentScripts.delete(scriptId); + scriptIdsMap.delete(id); + scriptIds.push(scriptId); + } + extension.updateContentScripts(); + + ExtensionScriptingStore.persistAll(extension); + + await extension.broadcast("Extension:UnregisterContentScripts", { + id: extension.id, + scriptIds, + }); + }, + + updateContentScripts: async scripts => { + // Map + const scriptIdsMap = gScriptIdsMap.get(extension); + // Map + const scriptsToUpdate = new Map(); + + for (const script of scripts) { + ensureValidScriptId(script.id); + + if (!scriptIdsMap.has(script.id)) { + throw new ExtensionError( + `Content script with id "${script.id}" does not exist.` + ); + } + + if (scriptsToUpdate.has(script.id)) { + throw new ExtensionError( + `Script ID "${script.id}" found more than once in 'scripts' array.` + ); + } + + // Retrieve the existing script options. + const scriptId = scriptIdsMap.get(script.id); + const options = extension.registeredContentScripts.get(scriptId); + + // Use existing values if not specified in the update. + script.allFrames ??= options.allFrames; + script.css ??= options.cssPaths; + script.excludeMatches ??= options.excludeMatches; + script.js ??= options.jsPaths; + script.matches ??= options.matches; + script.runAt ??= options.runAt; + script.persistAcrossSessions ??= options.persistAcrossSessions; + + ensureValidScriptParams(extension, script); + + scriptsToUpdate.set(script.id, { + ...makeInternalContentScript(extension, script), + // Re-use internal script ID. + scriptId, + }); + } + + for (const { scriptId, options } of scriptsToUpdate.values()) { + extension.registeredContentScripts.set(scriptId, options); + } + extension.updateContentScripts(); + + ExtensionScriptingStore.persistAll(extension); + + await extension.broadcast("Extension:UpdateContentScripts", { + id: extension.id, + scripts: Array.from(scriptsToUpdate.values()), + }); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-storage.js b/toolkit/components/extensions/parent/ext-storage.js new file mode 100644 index 0000000000..350ca0acfa --- /dev/null +++ b/toolkit/components/extensions/parent/ext-storage.js @@ -0,0 +1,366 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs", + ExtensionStorage: "resource://gre/modules/ExtensionStorage.sys.mjs", + ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.sys.mjs", + NativeManifests: "resource://gre/modules/NativeManifests.sys.mjs", + extensionStorageSession: "resource://gre/modules/ExtensionStorage.sys.mjs", +}); + +var { ExtensionError } = ExtensionUtils; +var { ignoreEvent } = ExtensionCommon; + +ChromeUtils.defineLazyGetter(this, "extensionStorageSync", () => { + // TODO bug 1637465: Remove Kinto-based implementation. + if (Services.prefs.getBoolPref("webextensions.storage.sync.kinto")) { + const { extensionStorageSyncKinto } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageSyncKinto.sys.mjs" + ); + return extensionStorageSyncKinto; + } + + const { extensionStorageSync } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageSync.sys.mjs" + ); + return extensionStorageSync; +}); + +const enforceNoTemporaryAddon = extensionId => { + const EXCEPTION_MESSAGE = + "The storage API will not work with a temporary addon ID. " + + "Please add an explicit addon ID to your manifest. " + + "For more information see https://mzl.la/3lPk1aE."; + if (AddonManagerPrivate.isTemporaryInstallID(extensionId)) { + throw new ExtensionError(EXCEPTION_MESSAGE); + } +}; + +// WeakMap[extension -> Promise] +const managedStorage = new WeakMap(); + +const lookupManagedStorage = async (extensionId, context) => { + if (Services.policies) { + let extensionPolicy = Services.policies.getExtensionPolicy(extensionId); + if (extensionPolicy) { + return ExtensionStorage._serializableMap(extensionPolicy); + } + } + let info = await NativeManifests.lookupManifest( + "storage", + extensionId, + context + ); + if (info) { + return ExtensionStorage._serializableMap(info.manifest.data); + } + return null; +}; + +this.storage = class extends ExtensionAPIPersistent { + constructor(extension) { + super(extension); + + const messageName = `Extension:StorageLocalOnChanged:${extension.uuid}`; + Services.ppmm.addMessageListener(messageName, this); + this.clearStorageChangedListener = () => { + Services.ppmm.removeMessageListener(messageName, this); + }; + } + + PERSISTENT_EVENTS = { + onChanged({ context, fire }) { + let unregisterLocal = this.registerLocalChangedListener(changes => { + // |changes| is already serialized. Send the raw value, so that it can + // be deserialized by the onChanged handler in child/ext-storage.js. + fire.raw(changes, "local"); + }); + + // Session storage is not exposed to content scripts, and `context` does + // not exist while setting up persistent listeners for an event page. + let unregisterSession; + if ( + !context || + context.envType === "addon_parent" || + context.envType === "devtools_parent" + ) { + unregisterSession = extensionStorageSession.registerListener( + this.extension, + changes => fire.async(changes, "session") + ); + } + + let unregisterSync = this.registerSyncChangedListener(changes => { + fire.async(changes, "sync"); + }); + + return { + unregister() { + unregisterLocal(); + unregisterSession?.(); + unregisterSync(); + }, + convert(_fire) { + fire = _fire; + }, + }; + }, + "local.onChanged"({ fire }) { + let unregister = this.registerLocalChangedListener(changes => { + // |changes| is already serialized. Send the raw value, so that it can + // be deserialized by the onChanged handler in child/ext-storage.js. + fire.raw(changes); + }); + return { + unregister, + convert(_fire) { + fire = _fire; + }, + }; + }, + "session.onChanged"({ fire }) { + let unregister = extensionStorageSession.registerListener( + this.extension, + changes => fire.async(changes) + ); + + return { + unregister, + convert(_fire) { + fire = _fire; + }, + }; + }, + "sync.onChanged"({ fire }) { + let unregister = this.registerSyncChangedListener(changes => { + fire.async(changes); + }); + return { + unregister, + convert(_fire) { + fire = _fire; + }, + }; + }, + }; + + registerLocalChangedListener(onStorageLocalChanged) { + const extensionId = this.extension.id; + ExtensionStorage.addOnChangedListener(extensionId, onStorageLocalChanged); + ExtensionStorageIDB.addOnChangedListener( + extensionId, + onStorageLocalChanged + ); + return () => { + ExtensionStorage.removeOnChangedListener( + extensionId, + onStorageLocalChanged + ); + ExtensionStorageIDB.removeOnChangedListener( + extensionId, + onStorageLocalChanged + ); + }; + } + + registerSyncChangedListener(onStorageSyncChanged) { + const { extension } = this; + let closeCallback; + // The ExtensionStorageSyncKinto implementation of addOnChangedListener + // relies on context.callOnClose (via ExtensionStorageSync.registerInUse) + // to keep track of active users of the storage. We don't need to pass a + // real BaseContext instance, a dummy object with the callOnClose method + // works too. This enables us to register a primed listener before any + // context is available. + // TODO bug 1637465: Remove this when the Kinto backend is dropped. + let dummyContextForKinto = { + callOnClose({ close }) { + closeCallback = close; + }, + }; + extensionStorageSync.addOnChangedListener( + extension, + onStorageSyncChanged, + dummyContextForKinto + ); + return () => { + extensionStorageSync.removeOnChangedListener( + extension, + onStorageSyncChanged + ); + // May be void if ExtensionStorageSyncKinto.jsm was not used. + // ExtensionStorageSync.jsm does not use the context. + closeCallback?.(); + }; + } + + onShutdown() { + const { clearStorageChangedListener } = this; + this.clearStorageChangedListener = null; + + if (clearStorageChangedListener) { + clearStorageChangedListener(); + } + } + + receiveMessage({ name, data }) { + if (name !== `Extension:StorageLocalOnChanged:${this.extension.uuid}`) { + return; + } + + ExtensionStorageIDB.notifyListeners(this.extension.id, data); + } + + getAPI(context) { + let { extension } = context; + + return { + storage: { + local: { + async callMethodInParentProcess(method, args) { + const res = await ExtensionStorageIDB.selectBackend({ extension }); + if (!res.backendEnabled) { + return ExtensionStorage[method](extension.id, ...args); + } + + const persisted = extension.hasPermission("unlimitedStorage"); + const db = await ExtensionStorageIDB.open( + res.storagePrincipal.deserialize(this, true), + persisted + ); + try { + const changes = await db[method](...args); + if (changes) { + ExtensionStorageIDB.notifyListeners(extension.id, changes); + } + return changes; + } catch (err) { + const normalizedError = ExtensionStorageIDB.normalizeStorageError( + { + error: err, + extensionId: extension.id, + storageMethod: method, + } + ).message; + return Promise.reject({ + message: String(normalizedError), + }); + } + }, + // Private storage.local JSONFile backend methods (used internally by the child + // ext-storage.js module). + JSONFileBackend: { + get(spec) { + return ExtensionStorage.get(extension.id, spec); + }, + set(items) { + return ExtensionStorage.set(extension.id, items); + }, + remove(keys) { + return ExtensionStorage.remove(extension.id, keys); + }, + clear() { + return ExtensionStorage.clear(extension.id); + }, + }, + // Private storage.local IDB backend methods (used internally by the child ext-storage.js + // module). + IDBBackend: { + selectBackend() { + return ExtensionStorageIDB.selectBackend(context); + }, + }, + onChanged: new EventManager({ + context, + module: "storage", + event: "local.onChanged", + extensionApi: this, + }).api(), + }, + + session: { + get(items) { + return extensionStorageSession.get(extension, items); + }, + set(items) { + extensionStorageSession.set(extension, items); + }, + remove(keys) { + extensionStorageSession.remove(extension, keys); + }, + clear() { + extensionStorageSession.clear(extension); + }, + onChanged: new EventManager({ + context, + module: "storage", + event: "session.onChanged", + extensionApi: this, + }).api(), + }, + + sync: { + get(spec) { + enforceNoTemporaryAddon(extension.id); + return extensionStorageSync.get(extension, spec, context); + }, + set(items) { + enforceNoTemporaryAddon(extension.id); + return extensionStorageSync.set(extension, items, context); + }, + remove(keys) { + enforceNoTemporaryAddon(extension.id); + return extensionStorageSync.remove(extension, keys, context); + }, + clear() { + enforceNoTemporaryAddon(extension.id); + return extensionStorageSync.clear(extension, context); + }, + getBytesInUse(keys) { + enforceNoTemporaryAddon(extension.id); + return extensionStorageSync.getBytesInUse(extension, keys, context); + }, + onChanged: new EventManager({ + context, + module: "storage", + event: "sync.onChanged", + extensionApi: this, + }).api(), + }, + + managed: { + async get(keys) { + enforceNoTemporaryAddon(extension.id); + let lookup = managedStorage.get(extension); + + if (!lookup) { + lookup = lookupManagedStorage(extension.id, context); + managedStorage.set(extension, lookup); + } + + let data = await lookup; + if (!data) { + return Promise.reject({ + message: "Managed storage manifest not found", + }); + } + return ExtensionStorage._filterProperties(extension.id, data, keys); + }, + // managed storage is currently initialized once. + onChanged: ignoreEvent(context, "storage.managed.onChanged"), + }, + + onChanged: new EventManager({ + context, + module: "storage", + event: "onChanged", + extensionApi: this, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-tabs-base.js b/toolkit/components/extensions/parent/ext-tabs-base.js new file mode 100644 index 0000000000..64ca9c0627 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-tabs-base.js @@ -0,0 +1,2377 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +/* globals EventEmitter */ + +ChromeUtils.defineESModuleGetters(this, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "containersEnabled", + "privacy.userContext.enabled" +); + +var { DefaultMap, DefaultWeakMap, ExtensionError, parseMatchPatterns } = + ExtensionUtils; + +var { defineLazyGetter } = ExtensionCommon; + +/** + * The platform-specific type of native tab objects, which are wrapped by + * TabBase instances. + * + * @typedef {object | XULElement} NativeTab + */ + +/** + * @typedef {object} MutedInfo + * @property {boolean} muted + * True if the tab is currently muted, false otherwise. + * @property {string} [reason] + * The reason the tab is muted. Either "user", if the tab was muted by a + * user, or "extension", if it was muted by an extension. + * @property {string} [extensionId] + * If the tab was muted by an extension, contains the internal ID of that + * extension. + */ + +/** + * A platform-independent base class for extension-specific wrappers around + * native tab objects. + * + * @param {Extension} extension + * The extension object for which this wrapper is being created. Used to + * determine permissions for access to certain properties and + * functionality. + * @param {NativeTab} nativeTab + * The native tab object which is being wrapped. The type of this object + * varies by platform. + * @param {integer} id + * The numeric ID of this tab object. This ID should be the same for + * every extension, and for the lifetime of the tab. + */ +class TabBase { + constructor(extension, nativeTab, id) { + this.extension = extension; + this.tabManager = extension.tabManager; + this.id = id; + this.nativeTab = nativeTab; + this.activeTabWindowID = null; + + if (!extension.privateBrowsingAllowed && this._incognito) { + throw new ExtensionError(`Invalid tab ID: ${id}`); + } + } + + /** + * Capture the visible area of this tab, and return the result as a data: URI. + * + * @param {BaseContext} context + * The extension context for which to perform the capture. + * @param {number} zoom + * The current zoom for the page. + * @param {object} [options] + * The options with which to perform the capture. + * @param {string} [options.format = "png"] + * The image format in which to encode the captured data. May be one of + * "png" or "jpeg". + * @param {integer} [options.quality = 92] + * The quality at which to encode the captured image data, ranging from + * 0 to 100. Has no effect for the "png" format. + * @param {DOMRectInit} [options.rect] + * Area of the document to render, in CSS pixels, relative to the page. + * If null, the currently visible viewport is rendered. + * @param {number} [options.scale] + * The scale to render at, defaults to devicePixelRatio. + * @returns {Promise} + */ + async capture(context, zoom, options) { + let win = this.browser.ownerGlobal; + let scale = options?.scale || win.devicePixelRatio; + let rect = options?.rect && win.DOMRect.fromRect(options.rect); + + // We only allow mozilla addons to use the resetScrollPosition option, + // since it's not standardized. + let resetScrollPosition = false; + if (!context.extension.restrictSchemes) { + resetScrollPosition = !!options?.resetScrollPosition; + } + + let wgp = this.browsingContext.currentWindowGlobal; + let image = await wgp.drawSnapshot( + rect, + scale * zoom, + "white", + resetScrollPosition + ); + + let doc = Services.appShell.hiddenDOMWindow.document; + let canvas = doc.createElement("canvas"); + canvas.width = image.width; + canvas.height = image.height; + + let ctx = canvas.getContext("2d", { alpha: false }); + ctx.drawImage(image, 0, 0); + image.close(); + + return canvas.toDataURL(`image/${options?.format}`, options?.quality / 100); + } + + /** + * @property {integer | null} innerWindowID + * The last known innerWindowID loaded into this tab's docShell. This + * property must remain in sync with the last known values of + * properties such as `url` and `title`. Any operations on the content + * of an out-of-process tab will automatically fail if the + * innerWindowID of the tab when the message is received does not match + * the value of this property when the message was sent. + * @readonly + */ + get innerWindowID() { + return this.browser.innerWindowID; + } + + /** + * @property {boolean} hasTabPermission + * Returns true if the extension has permission to access restricted + * properties of this tab, such as `url`, `title`, and `favIconUrl`. + * @readonly + */ + get hasTabPermission() { + return ( + this.extension.hasPermission("tabs") || + this.hasActiveTabPermission || + this.matchesHostPermission + ); + } + + /** + * @property {boolean} hasActiveTabPermission + * Returns true if the extension has the "activeTab" permission, and + * has been granted access to this tab due to a user executing an + * extension action. + * + * If true, the extension may load scripts and CSS into this tab, and + * access restricted properties, such as its `url`. + * @readonly + */ + get hasActiveTabPermission() { + return ( + (this.extension.originControls || + this.extension.hasPermission("activeTab")) && + this.activeTabWindowID != null && + this.activeTabWindowID === this.innerWindowID + ); + } + + /** + * @property {boolean} matchesHostPermission + * Returns true if the extensions host permissions match the current tab url. + * @readonly + */ + get matchesHostPermission() { + return this.extension.allowedOrigins.matches(this._uri); + } + + /** + * @property {boolean} incognito + * Returns true if this is a private browsing tab, false otherwise. + * @readonly + */ + get _incognito() { + return PrivateBrowsingUtils.isBrowserPrivate(this.browser); + } + + /** + * @property {string} _url + * Returns the current URL of this tab. Does not do any permission + * checks. + * @readonly + */ + get _url() { + return this.browser.currentURI.spec; + } + + /** + * @property {string | null} url + * Returns the current URL of this tab if the extension has permission + * to read it, or null otherwise. + * @readonly + */ + get url() { + if (this.hasTabPermission) { + return this._url; + } + } + + /** + * @property {nsIURI} _uri + * Returns the current URI of this tab. + * @readonly + */ + get _uri() { + return this.browser.currentURI; + } + + /** + * @property {string} _title + * Returns the current title of this tab. Does not do any permission + * checks. + * @readonly + */ + get _title() { + return this.browser.contentTitle || this.nativeTab.label; + } + + /** + * @property {nsIURI | null} title + * Returns the current title of this tab if the extension has permission + * to read it, or null otherwise. + * @readonly + */ + get title() { + if (this.hasTabPermission) { + return this._title; + } + } + + /** + * @property {string} _favIconUrl + * Returns the current favicon URL of this tab. Does not do any permission + * checks. + * @readonly + * @abstract + */ + get _favIconUrl() { + throw new Error("Not implemented"); + } + + /** + * @property {nsIURI | null} faviconUrl + * Returns the current faviron URL of this tab if the extension has permission + * to read it, or null otherwise. + * @readonly + */ + get favIconUrl() { + if (this.hasTabPermission) { + return this._favIconUrl; + } + } + + /** + * @property {integer} lastAccessed + * Returns the last time the tab was accessed as the number of + * milliseconds since epoch. + * @readonly + * @abstract + */ + get lastAccessed() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} audible + * Returns true if the tab is currently playing audio, false otherwise. + * @readonly + * @abstract + */ + get audible() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} autoDiscardable + * Returns true if the tab can be discarded on memory pressure, false otherwise. + * @readonly + * @abstract + */ + get autoDiscardable() { + throw new Error("Not implemented"); + } + + /** + * @property {XULElement} browser + * Returns the XUL browser for the given tab. + * @readonly + * @abstract + */ + get browser() { + throw new Error("Not implemented"); + } + + /** + * @property {BrowsingContext} browsingContext + * Returns the BrowsingContext for the given tab. + * @readonly + */ + get browsingContext() { + return this.browser?.browsingContext; + } + + /** + * @property {FrameLoader} frameLoader + * Returns the frameloader for the given tab. + * @readonly + */ + get frameLoader() { + return this.browser && this.browser.frameLoader; + } + + /** + * @property {string} cookieStoreId + * Returns the cookie store identifier for the given tab. + * @readonly + * @abstract + */ + get cookieStoreId() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} openerTabId + * Returns the ID of the tab which opened this one. + * @readonly + */ + get openerTabId() { + return null; + } + + /** + * @property {integer} discarded + * Returns true if the tab is discarded. + * @readonly + * @abstract + */ + get discarded() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} height + * Returns the pixel height of the visible area of the tab. + * @readonly + * @abstract + */ + get height() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} hidden + * Returns true if the tab is hidden. + * @readonly + * @abstract + */ + get hidden() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} index + * Returns the index of the tab in its window's tab list. + * @readonly + * @abstract + */ + get index() { + throw new Error("Not implemented"); + } + + /** + * @property {MutedInfo} mutedInfo + * Returns information about the tab's current audio muting status. + * @readonly + * @abstract + */ + get mutedInfo() { + throw new Error("Not implemented"); + } + + /** + * @property {SharingState} sharingState + * Returns object with tab sharingState. + * @readonly + * @abstract + */ + get sharingState() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} pinned + * Returns true if the tab is pinned, false otherwise. + * @readonly + * @abstract + */ + get pinned() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} active + * Returns true if the tab is the currently-selected tab, false + * otherwise. + * @readonly + * @abstract + */ + get active() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} highlighted + * Returns true if the tab is highlighted. + * @readonly + * @abstract + */ + get highlighted() { + throw new Error("Not implemented"); + } + + /** + * @property {string} status + * Returns the current loading status of the tab. May be either + * "loading" or "complete". + * @readonly + * @abstract + */ + get status() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} height + * Returns the pixel height of the visible area of the tab. + * @readonly + * @abstract + */ + get width() { + throw new Error("Not implemented"); + } + + /** + * @property {DOMWindow} window + * Returns the browser window to which the tab belongs. + * @readonly + * @abstract + */ + get window() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} window + * Returns the numeric ID of the browser window to which the tab belongs. + * @readonly + * @abstract + */ + get windowId() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} attention + * Returns true if the tab is drawing attention. + * @readonly + * @abstract + */ + get attention() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} isArticle + * Returns true if the document in the tab can be rendered in reader + * mode. + * @readonly + * @abstract + */ + get isArticle() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} isInReaderMode + * Returns true if the document in the tab is being rendered in reader + * mode. + * @readonly + * @abstract + */ + get isInReaderMode() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} successorTabId + * @readonly + * @abstract + */ + get successorTabId() { + throw new Error("Not implemented"); + } + + /** + * Returns true if this tab matches the the given query info object. Omitted + * or null have no effect on the match. + * + * @param {object} queryInfo + * The query info against which to match. + * @param {boolean} [queryInfo.active] + * Matches against the exact value of the tab's `active` attribute. + * @param {boolean} [queryInfo.audible] + * Matches against the exact value of the tab's `audible` attribute. + * @param {boolean} [queryInfo.autoDiscardable] + * Matches against the exact value of the tab's `autoDiscardable` attribute. + * @param {string} [queryInfo.cookieStoreId] + * Matches against the exact value of the tab's `cookieStoreId` attribute. + * @param {boolean} [queryInfo.discarded] + * Matches against the exact value of the tab's `discarded` attribute. + * @param {boolean} [queryInfo.hidden] + * Matches against the exact value of the tab's `hidden` attribute. + * @param {boolean} [queryInfo.highlighted] + * Matches against the exact value of the tab's `highlighted` attribute. + * @param {integer} [queryInfo.index] + * Matches against the exact value of the tab's `index` attribute. + * @param {boolean} [queryInfo.muted] + * Matches against the exact value of the tab's `mutedInfo.muted` attribute. + * @param {boolean} [queryInfo.pinned] + * Matches against the exact value of the tab's `pinned` attribute. + * @param {string} [queryInfo.status] + * Matches against the exact value of the tab's `status` attribute. + * @param {string} [queryInfo.title] + * Matches against the exact value of the tab's `title` attribute. + * @param {string|boolean } [queryInfo.screen] + * Matches against the exact value of the tab's `sharingState.screen` attribute, or use true to match any screen sharing tab. + * @param {boolean} [queryInfo.camera] + * Matches against the exact value of the tab's `sharingState.camera` attribute. + * @param {boolean} [queryInfo.microphone] + * Matches against the exact value of the tab's `sharingState.microphone` attribute. + * + * Note: Per specification, this should perform a pattern match, rather + * than an exact value match, and will do so in the future. + * @param {MatchPattern} [queryInfo.url] + * Requires the tab's URL to match the given MatchPattern object. + * + * @returns {boolean} + * True if the tab matches the query. + */ + matches(queryInfo) { + const PROPS = [ + "active", + "audible", + "autoDiscardable", + "discarded", + "hidden", + "highlighted", + "index", + "openerTabId", + "pinned", + "status", + ]; + + function checkProperty(prop, obj) { + return queryInfo[prop] != null && queryInfo[prop] !== obj[prop]; + } + + if (PROPS.some(prop => checkProperty(prop, this))) { + return false; + } + + if (checkProperty("muted", this.mutedInfo)) { + return false; + } + + let state = this.sharingState; + if (["camera", "microphone"].some(prop => checkProperty(prop, state))) { + return false; + } + // query for screen can be boolean (ie. any) or string (ie. specific). + if (queryInfo.screen !== null) { + let match = + typeof queryInfo.screen == "boolean" + ? queryInfo.screen === !!state.screen + : queryInfo.screen === state.screen; + if (!match) { + return false; + } + } + + if (queryInfo.cookieStoreId) { + if (!queryInfo.cookieStoreId.includes(this.cookieStoreId)) { + return false; + } + } + + if (queryInfo.url || queryInfo.title) { + if (!this.hasTabPermission) { + return false; + } + // Using _uri and _title instead of url/title to avoid repeated permission checks. + if (queryInfo.url && !queryInfo.url.matches(this._uri)) { + return false; + } + if (queryInfo.title && !queryInfo.title.matches(this._title)) { + return false; + } + } + + return true; + } + + /** + * Converts this tab object to a JSON-compatible object containing the values + * of its properties which the extension is permitted to access, in the format + * required to be returned by WebExtension APIs. + * + * @param {object} [fallbackTabSize] + * A geometry data if the lazy geometry data for this tab hasn't been + * initialized yet. + * @returns {object} + */ + convert(fallbackTabSize = null) { + let result = { + id: this.id, + index: this.index, + windowId: this.windowId, + highlighted: this.highlighted, + active: this.active, + attention: this.attention, + pinned: this.pinned, + status: this.status, + hidden: this.hidden, + discarded: this.discarded, + incognito: this.incognito, + width: this.width, + height: this.height, + lastAccessed: this.lastAccessed, + audible: this.audible, + autoDiscardable: this.autoDiscardable, + mutedInfo: this.mutedInfo, + isArticle: this.isArticle, + isInReaderMode: this.isInReaderMode, + sharingState: this.sharingState, + successorTabId: this.successorTabId, + cookieStoreId: this.cookieStoreId, + }; + + // If the tab has not been fully layed-out yet, fallback to the geometry + // from a different tab (usually the currently active tab). + if (fallbackTabSize && (!result.width || !result.height)) { + result.width = fallbackTabSize.width; + result.height = fallbackTabSize.height; + } + + let opener = this.openerTabId; + if (opener) { + result.openerTabId = opener; + } + + if (this.hasTabPermission) { + for (let prop of ["url", "title", "favIconUrl"]) { + // We use the underscored variants here to avoid the redundant + // permissions checks imposed on the public properties. + let val = this[`_${prop}`]; + if (val) { + result[prop] = val; + } + } + } + + return result; + } + + /** + * Query each content process hosting subframes of the tab, return results. + * + * @param {string} message + * @param {object} options + * These options are also sent to the message handler in the + * `ExtensionContentChild`. + * @param {number[]} options.frameIds + * When omitted, all frames will be queried. + * @param {boolean} options.returnResultsWithFrameIds + * @returns {Promise[]} + */ + async queryContent(message, options) { + let { frameIds } = options; + + /** @type {Map} */ + let byProcess = new DefaultMap(() => []); + // We use this set to know which frame IDs are potentially invalid (as in + // not found when visiting the tab's BC tree below) when frameIds is a + // non-empty list of frame IDs. + let frameIdsSet = new Set(frameIds); + + // Recursively walk the tab's BC tree, find all frames, group by process. + function visit(bc) { + let win = bc.currentWindowGlobal; + let frameId = bc.parent ? bc.id : 0; + + if (win?.domProcess && (!frameIds || frameIdsSet.has(frameId))) { + byProcess.get(win.domProcess).push(win.innerWindowId); + frameIdsSet.delete(frameId); + } + + if (!frameIds || frameIdsSet.size > 0) { + bc.children.forEach(visit); + } + } + visit(this.browsingContext); + + if (frameIdsSet.size > 0) { + throw new ExtensionError( + `Invalid frame IDs: [${Array.from(frameIdsSet).join(", ")}].` + ); + } + + let promises = Array.from(byProcess.entries(), ([proc, windows]) => + proc.getActor("ExtensionContent").sendQuery(message, { windows, options }) + ); + + let results = await Promise.all(promises).catch(err => { + if (err.name === "DataCloneError") { + let fileName = options.jsPaths.slice(-1)[0] || ""; + let message = `Script '${fileName}' result is non-structured-clonable data`; + return Promise.reject({ message, fileName }); + } + throw err; + }); + results = results.flat(); + + if (!results.length) { + let errorMessage = "Missing host permission for the tab"; + if (!frameIds || frameIds.length > 1 || frameIds[0] !== 0) { + errorMessage += " or frames"; + } + + throw new ExtensionError(errorMessage); + } + + if (frameIds && frameIds.length === 1 && results.length > 1) { + throw new ExtensionError("Internal error: multiple windows matched"); + } + + return results; + } + + /** + * Inserts a script or stylesheet in the given tab, and returns a promise + * which resolves when the operation has completed. + * + * @param {BaseContext} context + * The extension context for which to perform the injection. + * @param {InjectDetails} details + * The InjectDetails object, specifying what to inject, where, and + * when. + * @param {string} kind + * The kind of data being injected. Either "script" or "css". + * @param {string} method + * The name of the method which was called to trigger the injection. + * Used to generate appropriate error messages on failure. + * + * @returns {Promise} + * Resolves to the result of the execution, once it has completed. + * @private + */ + _execute(context, details, kind, method) { + let options = { + jsPaths: [], + cssPaths: [], + removeCSS: method == "removeCSS", + extensionId: context.extension.id, + }; + + // We require a `code` or a `file` property, but we can't accept both. + if ((details.code === null) == (details.file === null)) { + return Promise.reject({ + message: `${method} requires either a 'code' or a 'file' property, but not both`, + }); + } + + if (details.frameId !== null && details.allFrames) { + return Promise.reject({ + message: `'frameId' and 'allFrames' are mutually exclusive`, + }); + } + + options.hasActiveTabPermission = this.hasActiveTabPermission; + options.matches = this.extension.allowedOrigins.patterns.map( + host => host.pattern + ); + + if (details.code !== null) { + options[`${kind}Code`] = details.code; + } + if (details.file !== null) { + let url = context.uri.resolve(details.file); + if (!this.extension.isExtensionURL(url)) { + return Promise.reject({ + message: "Files to be injected must be within the extension", + }); + } + options[`${kind}Paths`].push(url); + } + + if (details.allFrames) { + options.allFrames = true; + } else if (details.frameId !== null) { + options.frameIds = [details.frameId]; + } else if (!details.allFrames) { + options.frameIds = [0]; + } + + if (details.matchAboutBlank) { + options.matchAboutBlank = details.matchAboutBlank; + } + if (details.runAt !== null) { + options.runAt = details.runAt; + } else { + options.runAt = "document_idle"; + } + if (details.cssOrigin !== null) { + options.cssOrigin = details.cssOrigin; + } else { + options.cssOrigin = "author"; + } + + options.wantReturnValue = true; + + // The scripting API (defined in `parent/ext-scripting.js`) has its own + // `execute()` function that calls `queryContent()` as well. Make sure to + // keep both in sync when relevant. + return this.queryContent("Execute", options); + } + + /** + * Executes a script in the tab's content window, and returns a Promise which + * resolves to the result of the evaluation, or rejects to the value of any + * error the injection generates. + * + * @param {BaseContext} context + * The extension context for which to inject the script. + * @param {InjectDetails} details + * The InjectDetails object, specifying what to inject, where, and + * when. + * + * @returns {Promise} + * Resolves to the result of the evaluation of the given script, once + * it has completed, or rejects with any error the evaluation + * generates. + */ + executeScript(context, details) { + return this._execute(context, details, "js", "executeScript"); + } + + /** + * Injects CSS into the tab's content window, and returns a Promise which + * resolves when the injection is complete. + * + * @param {BaseContext} context + * The extension context for which to inject the script. + * @param {InjectDetails} details + * The InjectDetails object, specifying what to inject, and where. + * + * @returns {Promise} + * Resolves when the injection has completed. + */ + insertCSS(context, details) { + return this._execute(context, details, "css", "insertCSS").then(() => {}); + } + + /** + * Removes CSS which was previously into the tab's content window via + * `insertCSS`, and returns a Promise which resolves when the operation is + * complete. + * + * @param {BaseContext} context + * The extension context for which to remove the CSS. + * @param {InjectDetails} details + * The InjectDetails object, specifying what to remove, and from where. + * + * @returns {Promise} + * Resolves when the operation has completed. + */ + removeCSS(context, details) { + return this._execute(context, details, "css", "removeCSS").then(() => {}); + } +} + +defineLazyGetter(TabBase.prototype, "incognito", function () { + return this._incognito; +}); + +// Note: These must match the values in windows.json. +const WINDOW_ID_NONE = -1; +const WINDOW_ID_CURRENT = -2; + +/** + * A platform-independent base class for extension-specific wrappers around + * native browser windows + * + * @param {Extension} extension + * The extension object for which this wrapper is being created. + * @param {DOMWindow} window + * The browser DOM window which is being wrapped. + * @param {integer} id + * The numeric ID of this DOM window object. This ID should be the same for + * every extension, and for the lifetime of the window. + */ +class WindowBase { + constructor(extension, window, id) { + if (!extension.canAccessWindow(window)) { + throw new ExtensionError("extension cannot access window"); + } + this.extension = extension; + this.window = window; + this.id = id; + } + + /** + * @property {nsIAppWindow} appWindow + * The nsIAppWindow object for this browser window. + * @readonly + */ + get appWindow() { + return this.window.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow); + } + + /** + * Returns true if this window is the current window for the given extension + * context, false otherwise. + * + * @param {BaseContext} context + * The extension context for which to perform the check. + * + * @returns {boolean} + */ + isCurrentFor(context) { + if (context && context.currentWindow) { + return this.window === context.currentWindow; + } + return this.isLastFocused; + } + + /** + * @property {string} type + * The type of the window, as defined by the WebExtension API. May be + * either "normal" or "popup". + * @readonly + */ + get type() { + let { chromeFlags } = this.appWindow; + + if (chromeFlags & Ci.nsIWebBrowserChrome.CHROME_OPENAS_DIALOG) { + return "popup"; + } + + return "normal"; + } + + /** + * Converts this window object to a JSON-compatible object which may be + * returned to an extension, in the format required to be returned by + * WebExtension APIs. + * + * @param {object} [getInfo] + * An optional object, the properties of which determine what data is + * available on the result object. + * @param {boolean} [getInfo.populate] + * Of true, the result object will contain a `tabs` property, + * containing an array of converted Tab objects, one for each tab in + * the window. + * + * @returns {object} + */ + convert(getInfo) { + let result = { + id: this.id, + focused: this.focused, + top: this.top, + left: this.left, + width: this.width, + height: this.height, + incognito: this.incognito, + type: this.type, + state: this.state, + alwaysOnTop: this.alwaysOnTop, + title: this.title, + }; + + if (getInfo && getInfo.populate) { + result.tabs = Array.from(this.getTabs(), tab => tab.convert()); + } + + return result; + } + + /** + * Returns true if this window matches the the given query info object. Omitted + * or null have no effect on the match. + * + * @param {object} queryInfo + * The query info against which to match. + * @param {boolean} [queryInfo.currentWindow] + * Matches against against the return value of `isCurrentFor()` for the + * given context. + * @param {boolean} [queryInfo.lastFocusedWindow] + * Matches against the exact value of the window's `isLastFocused` attribute. + * @param {boolean} [queryInfo.windowId] + * Matches against the exact value of the window's ID, taking into + * account the special WINDOW_ID_CURRENT value. + * @param {string} [queryInfo.windowType] + * Matches against the exact value of the window's `type` attribute. + * @param {BaseContext} context + * The extension context for which the matching is being performed. + * Used to determine the current window for relevant properties. + * + * @returns {boolean} + * True if the window matches the query. + */ + matches(queryInfo, context) { + if ( + queryInfo.lastFocusedWindow !== null && + queryInfo.lastFocusedWindow !== this.isLastFocused + ) { + return false; + } + + if (queryInfo.windowType !== null && queryInfo.windowType !== this.type) { + return false; + } + + if (queryInfo.windowId !== null) { + if (queryInfo.windowId === WINDOW_ID_CURRENT) { + if (!this.isCurrentFor(context)) { + return false; + } + } else if (queryInfo.windowId !== this.id) { + return false; + } + } + + if ( + queryInfo.currentWindow !== null && + queryInfo.currentWindow !== this.isCurrentFor(context) + ) { + return false; + } + + return true; + } + + /** + * @property {boolean} focused + * Returns true if the browser window is currently focused. + * @readonly + * @abstract + */ + get focused() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} top + * Returns the pixel offset of the top of the window from the top of + * the screen. + * @readonly + * @abstract + */ + get top() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} left + * Returns the pixel offset of the left of the window from the left of + * the screen. + * @readonly + * @abstract + */ + get left() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} width + * Returns the pixel width of the window. + * @readonly + * @abstract + */ + get width() { + throw new Error("Not implemented"); + } + + /** + * @property {integer} height + * Returns the pixel height of the window. + * @readonly + * @abstract + */ + get height() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} incognito + * Returns true if this is a private browsing window, false otherwise. + * @readonly + * @abstract + */ + get incognito() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} alwaysOnTop + * Returns true if this window is constrained to always remain above + * other windows. + * @readonly + * @abstract + */ + get alwaysOnTop() { + throw new Error("Not implemented"); + } + + /** + * @property {boolean} isLastFocused + * Returns true if this is the browser window which most recently had + * focus. + * @readonly + * @abstract + */ + get isLastFocused() { + throw new Error("Not implemented"); + } + + /** + * @property {string} state + * Returns or sets the current state of this window, as determined by + * `getState()`. + * @abstract + */ + get state() { + throw new Error("Not implemented"); + } + + set state(state) { + throw new Error("Not implemented"); + } + + /** + * @property {nsIURI | null} title + * Returns the current title of this window if the extension has permission + * to read it, or null otherwise. + * @readonly + */ + get title() { + // activeTab may be null when a new window is adopting an existing tab as its first tab + // (See Bug 1458918 for rationale). + if (this.activeTab && this.activeTab.hasTabPermission) { + return this._title; + } + } + + // The JSDoc validator does not support @returns tags in abstract functions or + // star functions without return statements. + /* eslint-disable valid-jsdoc */ + /** + * Returns the window state of the given window. + * + * @param {DOMWindow} window + * The window for which to return a state. + * + * @returns {string} + * The window's state. One of "normal", "minimized", "maximized", + * "fullscreen", or "docked". + * @static + * @abstract + */ + static getState(window) { + throw new Error("Not implemented"); + } + + /** + * Returns an iterator of TabBase objects for each tab in this window. + * + * @returns {Iterator} + */ + getTabs() { + throw new Error("Not implemented"); + } + + /** + * Returns an iterator of TabBase objects for each highlighted tab in this window. + * + * @returns {Iterator} + */ + getHighlightedTabs() { + throw new Error("Not implemented"); + } + + /** + * @property {TabBase} The window's currently active tab. + */ + get activeTab() { + throw new Error("Not implemented"); + } + + /** + * Returns the window's tab at the specified index. + * + * @param {integer} index + * The index of the desired tab. + * + * @returns {TabBase|undefined} + */ + getTabAtIndex(index) { + throw new Error("Not implemented"); + } + /* eslint-enable valid-jsdoc */ +} + +Object.assign(WindowBase, { WINDOW_ID_NONE, WINDOW_ID_CURRENT }); + +/** + * The parameter type of "tab-attached" events, which are emitted when a + * pre-existing tab is attached to a new window. + * + * @typedef {object} TabAttachedEvent + * @property {NativeTab} tab + * The native tab object in the window to which the tab is being + * attached. This may be a different object than was used to represent + * the tab in the old window. + * @property {integer} tabId + * The ID of the tab being attached. + * @property {integer} newWindowId + * The ID of the window to which the tab is being attached. + * @property {integer} newPosition + * The position of the tab in the tab list of the new window. + */ + +/** + * The parameter type of "tab-detached" events, which are emitted when a + * pre-existing tab is detached from a window, in order to be attached to a new + * window. + * + * @typedef {object} TabDetachedEvent + * @property {NativeTab} tab + * The native tab object in the window from which the tab is being + * detached. This may be a different object than will be used to + * represent the tab in the new window. + * @property {NativeTab} adoptedBy + * The native tab object in the window to which the tab will be attached, + * and is adopting the contents of this tab. This may be a different + * object than the tab in the previous window. + * @property {integer} tabId + * The ID of the tab being detached. + * @property {integer} oldWindowId + * The ID of the window from which the tab is being detached. + * @property {integer} oldPosition + * The position of the tab in the tab list of the window from which it is + * being detached. + */ + +/** + * The parameter type of "tab-created" events, which are emitted when a + * new tab is created. + * + * @typedef {object} TabCreatedEvent + * @property {NativeTab} tab + * The native tab object for the tab which is being created. + */ + +/** + * The parameter type of "tab-removed" events, which are emitted when a + * tab is removed and destroyed. + * + * @typedef {object} TabRemovedEvent + * @property {NativeTab} tab + * The native tab object for the tab which is being removed. + * @property {integer} tabId + * The ID of the tab being removed. + * @property {integer} windowId + * The ID of the window from which the tab is being removed. + * @property {boolean} isWindowClosing + * True if the tab is being removed because the window is closing. + */ + +/** + * An object containing basic, extension-independent information about the window + * and tab that a XUL belongs to. + * + * @typedef {object} BrowserData + * @property {integer} tabId + * The numeric ID of the tab that a belongs to, or -1 if it + * does not belong to a tab. + * @property {integer} windowId + * The numeric ID of the browser window that a belongs to, or -1 + * if it does not belong to a browser window. + */ + +/** + * A platform-independent base class for the platform-specific TabTracker + * classes, which track the opening and closing of tabs, and manage the mapping + * of them between numeric IDs and native tab objects. + * + * Instances of this class are EventEmitters which emit the following events, + * each with an argument of the given type: + * + * - "tab-attached" {@link TabAttacheEvent} + * - "tab-detached" {@link TabDetachedEvent} + * - "tab-created" {@link TabCreatedEvent} + * - "tab-removed" {@link TabRemovedEvent} + */ +class TabTrackerBase extends EventEmitter { + on(...args) { + if (!this.initialized) { + this.init(); + } + + return super.on(...args); // eslint-disable-line mozilla/balanced-listeners + } + + /** + * Called to initialize the tab tracking listeners the first time that an + * event listener is added. + * + * @protected + * @abstract + */ + init() { + throw new Error("Not implemented"); + } + + // The JSDoc validator does not support @returns tags in abstract functions or + // star functions without return statements. + /* eslint-disable valid-jsdoc */ + /** + * Returns the numeric ID for the given native tab. + * + * @param {NativeTab} nativeTab + * The native tab for which to return an ID. + * + * @returns {integer} + * The tab's numeric ID. + * @abstract + */ + getId(nativeTab) { + throw new Error("Not implemented"); + } + + /** + * Returns the native tab with the given numeric ID. + * + * @param {integer} tabId + * The numeric ID of the tab to return. + * @param {*} default_ + * The value to return if no tab exists with the given ID. + * + * @returns {NativeTab} + * @throws {ExtensionError} + * If no tab exists with the given ID and a default return value is not + * provided. + * @abstract + */ + getTab(tabId, default_ = undefined) { + throw new Error("Not implemented"); + } + + /** + * Returns basic information about the tab and window that the given browser + * belongs to. + * + * @param {XULElement} browser + * The XUL browser element for which to return data. + * + * @returns {BrowserData} + * @abstract + */ + /* eslint-enable valid-jsdoc */ + getBrowserData(browser) { + throw new Error("Not implemented"); + } + + /** + * @property {NativeTab} activeTab + * Returns the native tab object for the active tab in the + * most-recently focused window, or null if no live tabs currently + * exist. + * @abstract + */ + get activeTab() { + throw new Error("Not implemented"); + } +} + +/** + * A browser progress listener instance which calls a given listener function + * whenever the status of the given browser changes. + * + * @param {function(object): void} listener + * A function to be called whenever the status of a tab's top-level + * browser. It is passed an object with a `browser` property pointing to + * the XUL browser, and a `status` property with a string description of + * the browser's status. + * @private + */ +class StatusListener { + constructor(listener) { + this.listener = listener; + } + + onStateChange(browser, webProgress, request, stateFlags, statusCode) { + if (!webProgress.isTopLevel) { + return; + } + + let status; + if (stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW) { + if (stateFlags & Ci.nsIWebProgressListener.STATE_START) { + status = "loading"; + } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + status = "complete"; + } + } else if ( + stateFlags & Ci.nsIWebProgressListener.STATE_STOP && + statusCode == Cr.NS_BINDING_ABORTED + ) { + status = "complete"; + } + + if (status) { + this.listener({ browser, status }); + } + } + + onLocationChange(browser, webProgress, request, locationURI, flags) { + if (webProgress.isTopLevel) { + let status = webProgress.isLoadingDocument ? "loading" : "complete"; + this.listener({ browser, status, url: locationURI.spec }); + } + } +} + +/** + * A platform-independent base class for the platform-specific WindowTracker + * classes, which track the opening and closing of windows, and manage the + * mapping of them between numeric IDs and native tab objects. + */ +class WindowTrackerBase extends EventEmitter { + constructor() { + super(); + + this._handleWindowOpened = this._handleWindowOpened.bind(this); + + this._openListeners = new Set(); + this._closeListeners = new Set(); + + this._listeners = new DefaultMap(() => new Set()); + + this._statusListeners = new DefaultWeakMap(listener => { + return new StatusListener(listener); + }); + + this._windowIds = new DefaultWeakMap(window => { + return window.docShell.outerWindowID; + }); + } + + isBrowserWindow(window) { + let { documentElement } = window.document; + + return documentElement.getAttribute("windowtype") === "navigator:browser"; + } + + // The JSDoc validator does not support @returns tags in abstract functions or + // star functions without return statements. + /* eslint-disable valid-jsdoc */ + /** + * Returns an iterator for all currently active browser windows. + * + * @param {boolean} [includeInomplete = false] + * If true, include browser windows which are not yet fully loaded. + * Otherwise, only include windows which are. + * + * @returns {Iterator} + */ + /* eslint-enable valid-jsdoc */ + *browserWindows(includeIncomplete = false) { + // The window type parameter is only available once the window's document + // element has been created. This means that, when looking for incomplete + // browser windows, we need to ignore the type entirely for windows which + // haven't finished loading, since we would otherwise skip browser windows + // in their early loading stages. + // This is particularly important given that the "domwindowcreated" event + // fires for browser windows when they're in that in-between state, and just + // before we register our own "domwindowcreated" listener. + + for (let window of Services.wm.getEnumerator("")) { + let ok = includeIncomplete; + if (window.document.readyState === "complete") { + ok = this.isBrowserWindow(window); + } + + if (ok) { + yield window; + } + } + } + + /** + * @property {DOMWindow|null} topWindow + * The currently active, or topmost, browser window, or null if no + * browser window is currently open. + * @readonly + */ + get topWindow() { + return Services.wm.getMostRecentWindow("navigator:browser"); + } + + /** + * @property {DOMWindow|null} topWindow + * The currently active, or topmost, browser window that is not + * private browsing, or null if no browser window is currently open. + * @readonly + */ + get topNonPBWindow() { + return Services.wm.getMostRecentNonPBWindow("navigator:browser"); + } + + /** + * Returns the top window accessible by the extension. + * + * @param {BaseContext} context + * The extension context for which to return the current window. + * + * @returns {DOMWindow|null} + */ + getTopWindow(context) { + if (context && !context.privateBrowsingAllowed) { + return this.topNonPBWindow; + } + return this.topWindow; + } + + /** + * Returns the numeric ID for the given browser window. + * + * @param {DOMWindow} window + * The DOM window for which to return an ID. + * + * @returns {integer} + * The window's numeric ID. + */ + getId(window) { + return this._windowIds.get(window); + } + + /** + * Returns the browser window to which the given context belongs, or the top + * browser window if the context does not belong to a browser window. + * + * @param {BaseContext} context + * The extension context for which to return the current window. + * + * @returns {DOMWindow|null} + */ + getCurrentWindow(context) { + return (context && context.currentWindow) || this.getTopWindow(context); + } + + /** + * Returns the browser window with the given ID. + * + * @param {integer} id + * The ID of the window to return. + * @param {BaseContext} context + * The extension context for which the matching is being performed. + * Used to determine the current window for relevant properties. + * @param {boolean} [strict = true] + * If false, undefined will be returned instead of throwing an error + * in case no window exists with the given ID. + * + * @returns {DOMWindow|undefined} + * @throws {ExtensionError} + * If no window exists with the given ID and `strict` is true. + */ + getWindow(id, context, strict = true) { + if (id === WINDOW_ID_CURRENT) { + return this.getCurrentWindow(context); + } + + let window = Services.wm.getOuterWindowWithId(id); + if ( + window && + !window.closed && + (window.document.readyState !== "complete" || + this.isBrowserWindow(window)) + ) { + if (!context || context.canAccessWindow(window)) { + // Tolerate incomplete windows because isBrowserWindow is only reliable + // once the window is fully loaded. + return window; + } + } + + if (strict) { + throw new ExtensionError(`Invalid window ID: ${id}`); + } + } + + /** + * @property {boolean} _haveListeners + * Returns true if any window open or close listeners are currently + * registered. + * @private + */ + get _haveListeners() { + return this._openListeners.size > 0 || this._closeListeners.size > 0; + } + + /** + * Register the given listener function to be called whenever a new browser + * window is opened. + * + * @param {function(DOMWindow): void} listener + * The listener function to register. + */ + addOpenListener(listener) { + if (!this._haveListeners) { + Services.ww.registerNotification(this); + } + + this._openListeners.add(listener); + + for (let window of this.browserWindows(true)) { + if (window.document.readyState !== "complete") { + window.addEventListener("load", this); + } + } + } + + /** + * Unregister a listener function registered in a previous addOpenListener + * call. + * + * @param {function(DOMWindow): void} listener + * The listener function to unregister. + */ + removeOpenListener(listener) { + this._openListeners.delete(listener); + + if (!this._haveListeners) { + Services.ww.unregisterNotification(this); + } + } + + /** + * Register the given listener function to be called whenever a browser + * window is closed. + * + * @param {function(DOMWindow): void} listener + * The listener function to register. + */ + addCloseListener(listener) { + if (!this._haveListeners) { + Services.ww.registerNotification(this); + } + + this._closeListeners.add(listener); + } + + /** + * Unregister a listener function registered in a previous addCloseListener + * call. + * + * @param {function(DOMWindow): void} listener + * The listener function to unregister. + */ + removeCloseListener(listener) { + this._closeListeners.delete(listener); + + if (!this._haveListeners) { + Services.ww.unregisterNotification(this); + } + } + + /** + * Handles load events for recently-opened windows, and adds additional + * listeners which may only be safely added when the window is fully loaded. + * + * @param {Event} event + * A DOM event to handle. + * @private + */ + handleEvent(event) { + if (event.type === "load") { + event.currentTarget.removeEventListener(event.type, this); + + let window = event.target.defaultView; + if (!this.isBrowserWindow(window)) { + return; + } + + for (let listener of this._openListeners) { + try { + listener(window); + } catch (e) { + Cu.reportError(e); + } + } + } + } + + /** + * Observes "domwindowopened" and "domwindowclosed" events, notifies the + * appropriate listeners, and adds necessary additional listeners to the new + * windows. + * + * @param {DOMWindow} window + * A DOM window. + * @param {string} topic + * The topic being observed. + * @private + */ + observe(window, topic) { + if (topic === "domwindowclosed") { + if (!this.isBrowserWindow(window)) { + return; + } + + window.removeEventListener("load", this); + for (let listener of this._closeListeners) { + try { + listener(window); + } catch (e) { + Cu.reportError(e); + } + } + } else if (topic === "domwindowopened") { + window.addEventListener("load", this); + } + } + + /** + * Add an event listener to be called whenever the given DOM event is received + * at the top level of any browser window. + * + * @param {string} type + * The type of event to listen for. May be any valid DOM event name, or + * one of the following special cases: + * + * - "progress": Adds a tab progress listener to every browser window. + * - "status": Adds a StatusListener to every tab of every browser + * window. + * - "domwindowopened": Acts as an alias for addOpenListener. + * - "domwindowclosed": Acts as an alias for addCloseListener. + * @param {Function | object} listener + * The listener to invoke in response to the given events. + * + * @returns {undefined} + */ + addListener(type, listener) { + if (type === "domwindowopened") { + return this.addOpenListener(listener); + } else if (type === "domwindowclosed") { + return this.addCloseListener(listener); + } + + if (this._listeners.size === 0) { + this.addOpenListener(this._handleWindowOpened); + } + + if (type === "status") { + listener = this._statusListeners.get(listener); + type = "progress"; + } + + this._listeners.get(type).add(listener); + + // Register listener on all existing windows. + for (let window of this.browserWindows()) { + this._addWindowListener(window, type, listener); + } + } + + /** + * Removes an event listener previously registered via an addListener call. + * + * @param {string} type + * The type of event to stop listening for. + * @param {Function | object} listener + * The listener to remove. + * + * @returns {undefined} + */ + removeListener(type, listener) { + if (type === "domwindowopened") { + return this.removeOpenListener(listener); + } else if (type === "domwindowclosed") { + return this.removeCloseListener(listener); + } + + if (type === "status") { + listener = this._statusListeners.get(listener); + type = "progress"; + } + + let listeners = this._listeners.get(type); + listeners.delete(listener); + + if (listeners.size === 0) { + this._listeners.delete(type); + if (this._listeners.size === 0) { + this.removeOpenListener(this._handleWindowOpened); + } + } + + // Unregister listener from all existing windows. + let useCapture = type === "focus" || type === "blur"; + for (let window of this.browserWindows()) { + if (type === "progress") { + this.removeProgressListener(window, listener); + } else { + window.removeEventListener(type, listener, useCapture); + } + } + } + + /** + * Adds a listener for the given event to the given window. + * + * @param {DOMWindow} window + * The browser window to which to add the listener. + * @param {string} eventType + * The type of DOM event to listen for, or "progress" to add a tab + * progress listener. + * @param {Function | object} listener + * The listener to add. + * @private + */ + _addWindowListener(window, eventType, listener) { + let useCapture = eventType === "focus" || eventType === "blur"; + + if (eventType === "progress") { + this.addProgressListener(window, listener); + } else { + window.addEventListener(eventType, listener, useCapture); + } + } + + /** + * A private method which is called whenever a new browser window is opened, + * and adds the necessary listeners to it. + * + * @param {DOMWindow} window + * The window being opened. + * @private + */ + _handleWindowOpened(window) { + for (let [eventType, listeners] of this._listeners) { + for (let listener of listeners) { + this._addWindowListener(window, eventType, listener); + } + } + } + + /** + * Adds a tab progress listener to the given browser window. + * + * @param {DOMWindow} window + * The browser window to which to add the listener. + * @param {object} listener + * The tab progress listener to add. + * @abstract + */ + addProgressListener(window, listener) { + throw new Error("Not implemented"); + } + + /** + * Removes a tab progress listener from the given browser window. + * + * @param {DOMWindow} window + * The browser window from which to remove the listener. + * @param {object} listener + * The tab progress listener to remove. + * @abstract + */ + removeProgressListener(window, listener) { + throw new Error("Not implemented"); + } +} + +/** + * Manages native tabs, their wrappers, and their dynamic permissions for a + * particular extension. + * + * @param {Extension} extension + * The extension for which to manage tabs. + */ +class TabManagerBase { + constructor(extension) { + this.extension = extension; + + this._tabs = new DefaultWeakMap(tab => this.wrapTab(tab)); + } + + /** + * If the extension has requested activeTab permission, grant it those + * permissions for the current inner window in the given native tab. + * + * @param {NativeTab} nativeTab + * The native tab for which to grant permissions. + */ + addActiveTabPermission(nativeTab) { + let tab = this.getWrapper(nativeTab); + if ( + this.extension.hasPermission("activeTab") || + (this.extension.originControls && + this.extension.optionalOrigins.matches(tab._uri)) + ) { + // Note that, unlike Chrome, we don't currently clear this permission with + // the tab navigates. If the inner window is revived from BFCache before + // we've granted this permission to a new inner window, the extension + // maintains its permissions for it. + tab.activeTabWindowID = tab.innerWindowID; + } + } + + /** + * Revoke the extension's activeTab permissions for the current inner window + * of the given native tab. + * + * @param {NativeTab} nativeTab + * The native tab for which to revoke permissions. + */ + revokeActiveTabPermission(nativeTab) { + this.getWrapper(nativeTab).activeTabWindowID = null; + } + + /** + * Returns true if the extension has requested activeTab permission, and has + * been granted permissions for the current inner window if this tab. + * + * @param {NativeTab} nativeTab + * The native tab for which to check permissions. + * @returns {boolean} + * True if the extension has activeTab permissions for this tab. + */ + hasActiveTabPermission(nativeTab) { + return this.getWrapper(nativeTab).hasActiveTabPermission; + } + + /** + * Activate MV3 content scripts if the extension has activeTab or an + * (ungranted) host permission. + * + * @param {NativeTab} nativeTab + */ + activateScripts(nativeTab) { + let tab = this.getWrapper(nativeTab); + if ( + this.extension.originControls && + !tab.matchesHostPermission && + (this.extension.optionalOrigins.matches(tab._uri) || + this.extension.hasPermission("activeTab")) && + (this.extension.contentScripts.length || + this.extension.registeredContentScripts.size) + ) { + tab.queryContent("ActivateScripts", { id: this.extension.id }); + } + } + + /** + * Returns true if the extension has permissions to access restricted + * properties of the given native tab. In practice, this means that it has + * either requested the "tabs" permission or has activeTab permissions for the + * given tab. + * + * NOTE: Never use this method on an object that is not a native tab + * for the current platform: this method implicitly generates a wrapper + * for the passed nativeTab parameter and the platform-specific tabTracker + * instance is likely to store it in a map which is cleared only when the + * tab is closed (and so, if nativeTab is not a real native tab, it will + * never be cleared from the platform-specific tabTracker instance), + * See Bug 1458918 for a rationale. + * + * @param {NativeTab} nativeTab + * The native tab for which to check permissions. + * @returns {boolean} + * True if the extension has permissions for this tab. + */ + hasTabPermission(nativeTab) { + return this.getWrapper(nativeTab).hasTabPermission; + } + + /** + * Returns this extension's TabBase wrapper for the given native tab. This + * method will always return the same wrapper object for any given native tab. + * + * @param {NativeTab} nativeTab + * The tab for which to return a wrapper. + * + * @returns {TabBase|undefined} + * The wrapper for this tab. + */ + getWrapper(nativeTab) { + if (this.canAccessTab(nativeTab)) { + return this._tabs.get(nativeTab); + } + } + + /** + * Determines access using extension context. + * + * @param {NativeTab} nativeTab + * The tab to check access on. + * @returns {boolean} + * True if the extension has permissions for this tab. + * @protected + * @abstract + */ + canAccessTab(nativeTab) { + throw new Error("Not implemented"); + } + + /** + * Converts the given native tab to a JSON-compatible object, in the format + * required to be returned by WebExtension APIs, which may be safely passed to + * extension code. + * + * @param {NativeTab} nativeTab + * The native tab to convert. + * @param {object} [fallbackTabSize] + * A geometry data if the lazy geometry data for this tab hasn't been + * initialized yet. + * + * @returns {object} + */ + convert(nativeTab, fallbackTabSize = null) { + return this.getWrapper(nativeTab).convert(fallbackTabSize); + } + + // The JSDoc validator does not support @returns tags in abstract functions or + // star functions without return statements. + /* eslint-disable valid-jsdoc */ + /** + * Returns an iterator of TabBase objects which match the given query info. + * + * @param {object | null} [queryInfo = null] + * An object containing properties on which to filter. May contain any + * properties which are recognized by {@link TabBase#matches} or + * {@link WindowBase#matches}. Unknown properties will be ignored. + * @param {BaseContext|null} [context = null] + * The extension context for which the matching is being performed. + * Used to determine the current window for relevant properties. + * + * @returns {Iterator} + */ + *query(queryInfo = null, context = null) { + if (queryInfo) { + if (queryInfo.url !== null) { + queryInfo.url = parseMatchPatterns([].concat(queryInfo.url), { + restrictSchemes: false, + }); + } + + if (queryInfo.cookieStoreId !== null) { + queryInfo.cookieStoreId = [].concat(queryInfo.cookieStoreId); + } + + if (queryInfo.title !== null) { + try { + queryInfo.title = new MatchGlob(queryInfo.title); + } catch (e) { + throw new ExtensionError(`Invalid title: ${queryInfo.title}`); + } + } + } + function* candidates(windowWrapper) { + if (queryInfo) { + let { active, highlighted, index } = queryInfo; + if (active === true) { + let { activeTab } = windowWrapper; + if (activeTab) { + yield activeTab; + } + return; + } + if (index != null) { + let tabWrapper = windowWrapper.getTabAtIndex(index); + if (tabWrapper) { + yield tabWrapper; + } + return; + } + if (highlighted === true) { + yield* windowWrapper.getHighlightedTabs(); + return; + } + } + yield* windowWrapper.getTabs(); + } + let windowWrappers = this.extension.windowManager.query(queryInfo, context); + for (let windowWrapper of windowWrappers) { + for (let tabWrapper of candidates(windowWrapper)) { + if (!queryInfo || tabWrapper.matches(queryInfo)) { + yield tabWrapper; + } + } + } + } + + /** + * Returns a TabBase wrapper for the tab with the given ID. + * + * @param {integer} tabId + * The ID of the tab for which to return a wrapper. + * + * @returns {TabBase} + * @throws {ExtensionError} + * If no tab exists with the given ID. + * @abstract + */ + get(tabId) { + throw new Error("Not implemented"); + } + + /** + * Returns a new TabBase instance wrapping the given native tab. + * + * @param {NativeTab} nativeTab + * The native tab for which to return a wrapper. + * + * @returns {TabBase} + * @protected + * @abstract + */ + /* eslint-enable valid-jsdoc */ + wrapTab(nativeTab) { + throw new Error("Not implemented"); + } +} + +/** + * Manages native browser windows and their wrappers for a particular extension. + * + * @param {Extension} extension + * The extension for which to manage windows. + */ +class WindowManagerBase { + constructor(extension) { + this.extension = extension; + + this._windows = new DefaultWeakMap(window => this.wrapWindow(window)); + } + + /** + * Converts the given browser window to a JSON-compatible object, in the + * format required to be returned by WebExtension APIs, which may be safely + * passed to extension code. + * + * @param {DOMWindow} window + * The browser window to convert. + * @param {*} args + * Additional arguments to be passed to {@link WindowBase#convert}. + * + * @returns {object} + */ + convert(window, ...args) { + return this.getWrapper(window).convert(...args); + } + + /** + * Returns this extension's WindowBase wrapper for the given browser window. + * This method will always return the same wrapper object for any given + * browser window. + * + * @param {DOMWindow} window + * The browser window for which to return a wrapper. + * + * @returns {WindowBase|undefined} + * The wrapper for this tab. + */ + getWrapper(window) { + if (this.extension.canAccessWindow(window)) { + return this._windows.get(window); + } + } + + /** + * Returns whether this window can be accessed by the extension in the given + * context. + * + * @param {DOMWindow} window + * The browser window that is being tested + * @param {BaseContext|null} context + * The extension context for which this test is being performed. + * @returns {boolean} + */ + canAccessWindow(window, context) { + return ( + (context && context.canAccessWindow(window)) || + this.extension.canAccessWindow(window) + ); + } + + // The JSDoc validator does not support @returns tags in abstract functions or + // star functions without return statements. + /* eslint-disable valid-jsdoc */ + /** + * Returns an iterator of WindowBase objects which match the given query info. + * + * @param {object | null} [queryInfo = null] + * An object containing properties on which to filter. May contain any + * properties which are recognized by {@link WindowBase#matches}. + * Unknown properties will be ignored. + * @param {BaseContext|null} [context = null] + * The extension context for which the matching is being performed. + * Used to determine the current window for relevant properties. + * + * @returns {Iterator} + */ + *query(queryInfo = null, context = null) { + function* candidates(windowManager) { + if (queryInfo) { + let { currentWindow, windowId, lastFocusedWindow } = queryInfo; + if (currentWindow === true && windowId == null) { + windowId = WINDOW_ID_CURRENT; + } + if (windowId != null) { + let window = global.windowTracker.getWindow(windowId, context, false); + if (window) { + yield windowManager.getWrapper(window); + } + return; + } + if (lastFocusedWindow === true) { + let window = global.windowTracker.getTopWindow(context); + if (window) { + yield windowManager.getWrapper(window); + } + return; + } + } + yield* windowManager.getAll(context); + } + for (let windowWrapper of candidates(this)) { + if (!queryInfo || windowWrapper.matches(queryInfo, context)) { + yield windowWrapper; + } + } + } + + /** + * Returns a WindowBase wrapper for the browser window with the given ID. + * + * @param {integer} windowId + * The ID of the browser window for which to return a wrapper. + * @param {BaseContext} context + * The extension context for which the matching is being performed. + * Used to determine the current window for relevant properties. + * + * @returns {WindowBase} + * @throws {ExtensionError} + * If no window exists with the given ID. + * @abstract + */ + get(windowId, context) { + throw new Error("Not implemented"); + } + + /** + * Returns an iterator of WindowBase wrappers for each currently existing + * browser window. + * + * @returns {Iterator} + * @abstract + */ + getAll() { + throw new Error("Not implemented"); + } + + /** + * Returns a new WindowBase instance wrapping the given browser window. + * + * @param {DOMWindow} window + * The browser window for which to return a wrapper. + * + * @returns {WindowBase} + * @protected + * @abstract + */ + wrapWindow(window) { + throw new Error("Not implemented"); + } + /* eslint-enable valid-jsdoc */ +} + +function getUserContextIdForCookieStoreId( + extension, + cookieStoreId, + isPrivateBrowsing +) { + if (!extension.hasPermission("cookies")) { + throw new ExtensionError( + `No permission for cookieStoreId: ${cookieStoreId}` + ); + } + + if (!isValidCookieStoreId(cookieStoreId)) { + throw new ExtensionError(`Illegal cookieStoreId: ${cookieStoreId}`); + } + + if (isPrivateBrowsing && !isPrivateCookieStoreId(cookieStoreId)) { + throw new ExtensionError( + `Illegal to set non-private cookieStoreId in a private window` + ); + } + + if (!isPrivateBrowsing && isPrivateCookieStoreId(cookieStoreId)) { + throw new ExtensionError( + `Illegal to set private cookieStoreId in a non-private window` + ); + } + + if (isContainerCookieStoreId(cookieStoreId)) { + if (PrivateBrowsingUtils.permanentPrivateBrowsing) { + // Container tabs are not supported in perma-private browsing mode - bug 1320757 + throw new ExtensionError( + `Contextual identities are unavailable in permanent private browsing mode` + ); + } + if (!containersEnabled) { + throw new ExtensionError(`Contextual identities are currently disabled`); + } + let userContextId = getContainerForCookieStoreId(cookieStoreId); + if (!userContextId) { + throw new ExtensionError( + `No cookie store exists with ID ${cookieStoreId}` + ); + } + if (!extension.canAccessContainer(userContextId)) { + throw new ExtensionError(`Cannot access ${cookieStoreId}`); + } + return userContextId; + } + + return Services.scriptSecurityManager.DEFAULT_USER_CONTEXT_ID; +} + +Object.assign(global, { + TabTrackerBase, + TabManagerBase, + TabBase, + WindowTrackerBase, + WindowManagerBase, + WindowBase, + getUserContextIdForCookieStoreId, +}); diff --git a/toolkit/components/extensions/parent/ext-telemetry.js b/toolkit/components/extensions/parent/ext-telemetry.js new file mode 100644 index 0000000000..cff568a038 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-telemetry.js @@ -0,0 +1,195 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + TelemetryController: "resource://gre/modules/TelemetryController.sys.mjs", + TelemetryUtils: "resource://gre/modules/TelemetryUtils.sys.mjs", +}); + +const SCALAR_TYPES = { + count: Ci.nsITelemetry.SCALAR_TYPE_COUNT, + string: Ci.nsITelemetry.SCALAR_TYPE_STRING, + boolean: Ci.nsITelemetry.SCALAR_TYPE_BOOLEAN, +}; + +// Currently unsupported on Android: blocked on 1220177. +// See 1280234 c67 for discussion. +function desktopCheck() { + if (AppConstants.MOZ_BUILD_APP !== "browser") { + throw new ExtensionUtils.ExtensionError( + "This API is only supported on desktop" + ); + } +} + +this.telemetry = class extends ExtensionAPI { + getAPI(context) { + let { extension } = context; + return { + telemetry: { + submitPing(type, payload, options) { + desktopCheck(); + const manifest = extension.manifest; + if (manifest.telemetry) { + throw new ExtensionUtils.ExtensionError( + "Encryption settings are defined, use submitEncryptedPing instead." + ); + } + + try { + TelemetryController.submitExternalPing(type, payload, options); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + submitEncryptedPing(payload, options) { + desktopCheck(); + + const manifest = extension.manifest; + if (!manifest.telemetry) { + throw new ExtensionUtils.ExtensionError( + "Encrypted telemetry pings require ping_type and public_key to be set in manifest." + ); + } + + if (!(options.schemaName && options.schemaVersion)) { + throw new ExtensionUtils.ExtensionError( + "Encrypted telemetry pings require schema name and version to be set in options object." + ); + } + + try { + const type = manifest.telemetry.ping_type; + + // Optional manifest entries. + if (manifest.telemetry.study_name) { + options.studyName = manifest.telemetry.study_name; + } + options.addPioneerId = manifest.telemetry.pioneer_id === true; + + // Required manifest entries. + options.useEncryption = true; + options.publicKey = manifest.telemetry.public_key.key; + options.encryptionKeyId = manifest.telemetry.public_key.id; + options.schemaNamespace = manifest.telemetry.schemaNamespace; + + TelemetryController.submitExternalPing(type, payload, options); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + canUpload() { + desktopCheck(); + // Note: remove the ternary and direct pref check when + // TelemetryController.canUpload() is implemented (bug 1440089). + try { + const result = + "canUpload" in TelemetryController + ? TelemetryController.canUpload() + : Services.prefs.getBoolPref( + TelemetryUtils.Preferences.FhrUploadEnabled, + false + ); + return result; + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + scalarAdd(name, value) { + desktopCheck(); + try { + Services.telemetry.scalarAdd(name, value); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + scalarSet(name, value) { + desktopCheck(); + try { + Services.telemetry.scalarSet(name, value); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + scalarSetMaximum(name, value) { + desktopCheck(); + try { + Services.telemetry.scalarSetMaximum(name, value); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + keyedScalarAdd(name, key, value) { + desktopCheck(); + try { + Services.telemetry.keyedScalarAdd(name, key, value); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + keyedScalarSet(name, key, value) { + desktopCheck(); + try { + Services.telemetry.keyedScalarSet(name, key, value); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + keyedScalarSetMaximum(name, key, value) { + desktopCheck(); + try { + Services.telemetry.keyedScalarSetMaximum(name, key, value); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + recordEvent(category, method, object, value, extra) { + desktopCheck(); + try { + Services.telemetry.recordEvent( + category, + method, + object, + value, + extra + ); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + registerScalars(category, data) { + desktopCheck(); + try { + // For each scalar in `data`, replace scalar.kind with + // the appropriate nsITelemetry constant. + Object.keys(data).forEach(scalar => { + data[scalar].kind = SCALAR_TYPES[data[scalar].kind]; + }); + Services.telemetry.registerScalars(category, data); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + setEventRecordingEnabled(category, enabled) { + desktopCheck(); + try { + Services.telemetry.setEventRecordingEnabled(category, enabled); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + registerEvents(category, data) { + desktopCheck(); + try { + Services.telemetry.registerEvents(category, data); + } catch (ex) { + throw new ExtensionUtils.ExtensionError(ex); + } + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-theme.js b/toolkit/components/extensions/parent/ext-theme.js new file mode 100644 index 0000000000..1280563dd0 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-theme.js @@ -0,0 +1,529 @@ +/* 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"; + +/* global windowTracker, EventManager, EventEmitter */ + +/* eslint-disable complexity */ + +ChromeUtils.defineESModuleGetters(this, { + LightweightThemeManager: + "resource://gre/modules/LightweightThemeManager.sys.mjs", +}); + +const onUpdatedEmitter = new EventEmitter(); + +// Represents an empty theme for convenience of use +const emptyTheme = { + details: { colors: null, images: null, properties: null }, +}; + +let defaultTheme = emptyTheme; +// Map[windowId -> Theme instance] +let windowOverrides = new Map(); + +/** + * Class representing either a global theme affecting all windows or an override on a specific window. + * Any extension updating the theme with a new global theme will replace the singleton defaultTheme. + */ +class Theme { + /** + * Creates a theme instance. + * + * @param {object} options + * @param {string} options.extension Extension that created the theme. + * @param {Integer} options.windowId The windowId where the theme is applied. + * @param {object} options.details + * @param {object} options.darkDetails + * @param {object} options.experiment + * @param {object} options.startupData + */ + constructor({ + extension, + details, + darkDetails, + windowId, + experiment, + startupData, + }) { + this.extension = extension; + this.details = details; + this.darkDetails = darkDetails; + this.windowId = windowId; + + if (startupData && startupData.lwtData) { + Object.assign(this, startupData); + } else { + // TODO(ntim): clean this in bug 1550090 + this.lwtStyles = {}; + this.lwtDarkStyles = null; + if (darkDetails) { + this.lwtDarkStyles = {}; + } + + if (experiment) { + if (extension.canUseThemeExperiment()) { + this.lwtStyles.experimental = { + colors: {}, + images: {}, + properties: {}, + }; + if (this.lwtDarkStyles) { + this.lwtDarkStyles.experimental = { + colors: {}, + images: {}, + properties: {}, + }; + } + const { baseURI } = this.extension; + if (experiment.stylesheet) { + experiment.stylesheet = baseURI.resolve(experiment.stylesheet); + } + this.experiment = experiment; + } else { + const { logger } = this.extension; + logger.warn("This extension is not allowed to run theme experiments"); + return; + } + } + } + this.load(); + } + + /** + * Loads a theme by reading the properties from the extension's manifest. + * This method will override any currently applied theme. + */ + load() { + if (!this.lwtData) { + this.loadDetails(this.details, this.lwtStyles); + if (this.darkDetails) { + this.loadDetails(this.darkDetails, this.lwtDarkStyles); + } + + this.lwtData = { + theme: this.lwtStyles, + darkTheme: this.lwtDarkStyles, + }; + + if (this.experiment) { + this.lwtData.experiment = this.experiment; + } + + this.extension.startupData = { + lwtData: this.lwtData, + lwtStyles: this.lwtStyles, + lwtDarkStyles: this.lwtDarkStyles, + experiment: this.experiment, + }; + this.extension.saveStartupData(); + } + + if (this.windowId) { + this.lwtData.window = windowTracker.getWindow( + this.windowId + ).docShell.outerWindowID; + windowOverrides.set(this.windowId, this); + } else { + windowOverrides.clear(); + defaultTheme = this; + LightweightThemeManager.fallbackThemeData = this.lwtData; + } + onUpdatedEmitter.emit("theme-updated", this.details, this.windowId); + + Services.obs.notifyObservers( + this.lwtData, + "lightweight-theme-styling-update" + ); + } + + /** + * @param {object} details Details + * @param {object} styles Styles object in which to store the colors. + */ + loadDetails(details, styles) { + if (details.colors) { + this.loadColors(details.colors, styles); + } + + if (details.images) { + this.loadImages(details.images, styles); + } + + if (details.properties) { + this.loadProperties(details.properties, styles); + } + + this.loadMetadata(this.extension, styles); + } + + /** + * Helper method for loading colors found in the extension's manifest. + * + * @param {object} colors Dictionary mapping color properties to values. + * @param {object} styles Styles object in which to store the colors. + */ + loadColors(colors, styles) { + for (let color of Object.keys(colors)) { + let val = colors[color]; + + if (!val) { + continue; + } + + let cssColor = val; + if (Array.isArray(val)) { + cssColor = + "rgb" + (val.length > 3 ? "a" : "") + "(" + val.join(",") + ")"; + } + + switch (color) { + case "frame": + styles.accentcolor = cssColor; + break; + case "frame_inactive": + styles.accentcolorInactive = cssColor; + break; + case "tab_background_text": + styles.textcolor = cssColor; + break; + case "toolbar": + styles.toolbarColor = cssColor; + break; + case "toolbar_text": + case "bookmark_text": + styles.toolbar_text = cssColor; + break; + case "icons": + styles.icon_color = cssColor; + break; + case "icons_attention": + styles.icon_attention_color = cssColor; + break; + case "tab_background_separator": + case "tab_loading": + case "tab_text": + case "tab_line": + case "tab_selected": + case "toolbar_field": + case "toolbar_field_text": + case "toolbar_field_border": + case "toolbar_field_focus": + case "toolbar_field_text_focus": + case "toolbar_field_border_focus": + case "toolbar_top_separator": + case "toolbar_bottom_separator": + case "toolbar_vertical_separator": + case "button_background_hover": + case "button_background_active": + case "popup": + case "popup_text": + case "popup_border": + case "popup_highlight": + case "popup_highlight_text": + case "ntp_background": + case "ntp_card_background": + case "ntp_text": + case "sidebar": + case "sidebar_border": + case "sidebar_text": + case "sidebar_highlight": + case "sidebar_highlight_text": + case "toolbar_field_highlight": + case "toolbar_field_highlight_text": + styles[color] = cssColor; + break; + default: + if ( + this.experiment && + this.experiment.colors && + color in this.experiment.colors + ) { + styles.experimental.colors[color] = cssColor; + } else { + const { logger } = this.extension; + logger.warn(`Unrecognized theme property found: colors.${color}`); + } + break; + } + } + } + + /** + * Helper method for loading images found in the extension's manifest. + * + * @param {object} images Dictionary mapping image properties to values. + * @param {object} styles Styles object in which to store the colors. + */ + loadImages(images, styles) { + const { baseURI, logger } = this.extension; + + for (let image of Object.keys(images)) { + let val = images[image]; + + if (!val) { + continue; + } + + switch (image) { + case "additional_backgrounds": { + let backgroundImages = val.map(img => baseURI.resolve(img)); + styles.additionalBackgrounds = backgroundImages; + break; + } + case "theme_frame": { + let resolvedURL = baseURI.resolve(val); + styles.headerURL = resolvedURL; + break; + } + default: { + if ( + this.experiment && + this.experiment.images && + image in this.experiment.images + ) { + styles.experimental.images[image] = baseURI.resolve(val); + } else { + logger.warn(`Unrecognized theme property found: images.${image}`); + } + break; + } + } + } + } + + /** + * Helper method for preparing properties found in the extension's manifest. + * Properties are commonly used to specify more advanced behavior of colors, + * images or icons. + * + * @param {object} properties Dictionary mapping properties to values. + * @param {object} styles Styles object in which to store the colors. + */ + loadProperties(properties, styles) { + let additionalBackgroundsCount = + (styles.additionalBackgrounds && styles.additionalBackgrounds.length) || + 0; + const assertValidAdditionalBackgrounds = (property, valueCount) => { + const { logger } = this.extension; + if (!additionalBackgroundsCount) { + logger.warn( + `The '${property}' property takes effect only when one ` + + `or more additional background images are specified using the 'additional_backgrounds' property.` + ); + return false; + } + if (additionalBackgroundsCount !== valueCount) { + logger.warn( + `The amount of values specified for '${property}' ` + + `(${valueCount}) is not equal to the amount of additional background ` + + `images (${additionalBackgroundsCount}), which may lead to unexpected results.` + ); + } + return true; + }; + + for (let property of Object.getOwnPropertyNames(properties)) { + let val = properties[property]; + + if (!val) { + continue; + } + + switch (property) { + case "additional_backgrounds_alignment": { + if (!assertValidAdditionalBackgrounds(property, val.length)) { + break; + } + + styles.backgroundsAlignment = val.join(","); + break; + } + case "additional_backgrounds_tiling": { + if (!assertValidAdditionalBackgrounds(property, val.length)) { + break; + } + + let tiling = []; + for (let i = 0, l = styles.additionalBackgrounds.length; i < l; ++i) { + tiling.push(val[i] || "no-repeat"); + } + styles.backgroundsTiling = tiling.join(","); + break; + } + case "color_scheme": + case "content_color_scheme": { + styles[property] = val; + break; + } + default: { + if ( + this.experiment && + this.experiment.properties && + property in this.experiment.properties + ) { + styles.experimental.properties[property] = val; + } else { + const { logger } = this.extension; + logger.warn( + `Unrecognized theme property found: properties.${property}` + ); + } + break; + } + } + } + } + + /** + * Helper method for loading extension metadata required by downstream + * consumers. + * + * @param {object} extension Extension object. + * @param {object} styles Styles object in which to store the colors. + */ + loadMetadata(extension, styles) { + styles.id = extension.id; + styles.version = extension.version; + } + + static unload(windowId) { + let lwtData = { + theme: null, + }; + + if (windowId) { + lwtData.window = windowTracker.getWindow(windowId).docShell.outerWindowID; + windowOverrides.delete(windowId); + } else { + windowOverrides.clear(); + defaultTheme = emptyTheme; + LightweightThemeManager.fallbackThemeData = null; + } + onUpdatedEmitter.emit("theme-updated", {}, windowId); + + Services.obs.notifyObservers(lwtData, "lightweight-theme-styling-update"); + } +} + +this.theme = class extends ExtensionAPIPersistent { + PERSISTENT_EVENTS = { + onUpdated({ fire, context }) { + let callback = (event, theme, windowId) => { + if (windowId) { + // Force access validation for incognito mode by getting the window. + if (windowTracker.getWindow(windowId, context, false)) { + fire.async({ theme, windowId }); + } + } else { + fire.async({ theme }); + } + }; + + onUpdatedEmitter.on("theme-updated", callback); + return { + unregister() { + onUpdatedEmitter.off("theme-updated", callback); + }, + convert(_fire, _context) { + fire = _fire; + context = _context; + }, + }; + }, + }; + + onManifestEntry(entryName) { + let { extension } = this; + let { manifest } = extension; + + defaultTheme = new Theme({ + extension, + details: manifest.theme, + darkDetails: manifest.dark_theme, + experiment: manifest.theme_experiment, + startupData: extension.startupData, + }); + } + + onShutdown(isAppShutdown) { + if (isAppShutdown) { + return; + } + + let { extension } = this; + for (let [windowId, theme] of windowOverrides) { + if (theme.extension === extension) { + Theme.unload(windowId); + } + } + + if (defaultTheme.extension === extension) { + Theme.unload(); + } + } + + getAPI(context) { + let { extension } = context; + + return { + theme: { + getCurrent: windowId => { + // Take last focused window when no ID is supplied. + if (!windowId) { + windowId = windowTracker.getId(windowTracker.topWindow); + } + // Force access validation for incognito mode by getting the window. + if (!windowTracker.getWindow(windowId, context)) { + return Promise.reject(`Invalid window ID: ${windowId}`); + } + + if (windowOverrides.has(windowId)) { + return Promise.resolve(windowOverrides.get(windowId).details); + } + return Promise.resolve(defaultTheme.details); + }, + update: (windowId, details) => { + if (windowId) { + const browserWindow = windowTracker.getWindow(windowId, context); + if (!browserWindow) { + return Promise.reject(`Invalid window ID: ${windowId}`); + } + } + + new Theme({ + extension, + details, + windowId, + experiment: this.extension.manifest.theme_experiment, + }); + }, + reset: windowId => { + if (windowId) { + const browserWindow = windowTracker.getWindow(windowId, context); + if (!browserWindow) { + return Promise.reject(`Invalid window ID: ${windowId}`); + } + + let theme = windowOverrides.get(windowId) || defaultTheme; + if (theme.extension !== extension) { + return; + } + } else if (defaultTheme.extension !== extension) { + return; + } + + Theme.unload(windowId); + }, + onUpdated: new EventManager({ + context, + module: "theme", + event: "onUpdated", + extensionApi: this, + }).api(), + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-toolkit.js b/toolkit/components/extensions/parent/ext-toolkit.js new file mode 100644 index 0000000000..c672cb96c0 --- /dev/null +++ b/toolkit/components/extensions/parent/ext-toolkit.js @@ -0,0 +1,130 @@ +/* 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"; + +// These are defined on "global" which is used for the same scopes as the other +// ext-*.js files. +/* exported getCookieStoreIdForTab, getCookieStoreIdForContainer, + getContainerForCookieStoreId, + isValidCookieStoreId, isContainerCookieStoreId, + EventManager, URL */ +/* global getCookieStoreIdForTab:false, + getCookieStoreIdForContainer:false, + getContainerForCookieStoreId: false, + isValidCookieStoreId:false, isContainerCookieStoreId:false, + isDefaultCookieStoreId: false, isPrivateCookieStoreId:false, + EventManager: false */ + +ChromeUtils.defineESModuleGetters(this, { + ContextualIdentityService: + "resource://gre/modules/ContextualIdentityService.sys.mjs", +}); + +XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]); + +var { ExtensionCommon } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionCommon.sys.mjs" +); + +var { ExtensionError } = ExtensionUtils; + +global.EventEmitter = ExtensionCommon.EventEmitter; +global.EventManager = ExtensionCommon.EventManager; + +/* globals DEFAULT_STORE, PRIVATE_STORE, CONTAINER_STORE */ + +global.DEFAULT_STORE = "firefox-default"; +global.PRIVATE_STORE = "firefox-private"; +global.CONTAINER_STORE = "firefox-container-"; + +global.getCookieStoreIdForTab = function (data, tab) { + if (data.incognito) { + return PRIVATE_STORE; + } + + if (tab.userContextId) { + return getCookieStoreIdForContainer(tab.userContextId); + } + + return DEFAULT_STORE; +}; + +global.getCookieStoreIdForOriginAttributes = function (originAttributes) { + if (originAttributes.privateBrowsingId) { + return PRIVATE_STORE; + } + + if (originAttributes.userContextId) { + return getCookieStoreIdForContainer(originAttributes.userContextId); + } + + return DEFAULT_STORE; +}; + +global.isPrivateCookieStoreId = function (storeId) { + return storeId == PRIVATE_STORE; +}; + +global.isDefaultCookieStoreId = function (storeId) { + return storeId == DEFAULT_STORE; +}; + +global.isContainerCookieStoreId = function (storeId) { + return storeId !== null && storeId.startsWith(CONTAINER_STORE); +}; + +global.getCookieStoreIdForContainer = function (containerId) { + return CONTAINER_STORE + containerId; +}; + +global.getContainerForCookieStoreId = function (storeId) { + if (!isContainerCookieStoreId(storeId)) { + return null; + } + + let containerId = storeId.substring(CONTAINER_STORE.length); + + if (AppConstants.platform === "android") { + return parseInt(containerId, 10); + } // TODO: Bug 1643740, support ContextualIdentityService on Android + + if (ContextualIdentityService.getPublicIdentityFromId(containerId)) { + return parseInt(containerId, 10); + } + + return null; +}; + +global.isValidCookieStoreId = function (storeId) { + return ( + isDefaultCookieStoreId(storeId) || + isPrivateCookieStoreId(storeId) || + isContainerCookieStoreId(storeId) + ); +}; + +global.getOriginAttributesPatternForCookieStoreId = function (cookieStoreId) { + if (isDefaultCookieStoreId(cookieStoreId)) { + return { + userContextId: Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID, + privateBrowsingId: + Ci.nsIScriptSecurityManager.DEFAULT_PRIVATE_BROWSING_ID, + }; + } + if (isPrivateCookieStoreId(cookieStoreId)) { + return { + userContextId: Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID, + privateBrowsingId: 1, + }; + } + if (isContainerCookieStoreId(cookieStoreId)) { + let userContextId = getContainerForCookieStoreId(cookieStoreId); + if (userContextId !== null) { + return { userContextId }; + } + } + + throw new ExtensionError("Invalid cookieStoreId"); +}; diff --git a/toolkit/components/extensions/parent/ext-userScripts.js b/toolkit/components/extensions/parent/ext-userScripts.js new file mode 100644 index 0000000000..9c008a4e8d --- /dev/null +++ b/toolkit/components/extensions/parent/ext-userScripts.js @@ -0,0 +1,158 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +var { ExtensionUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionUtils.sys.mjs" +); + +var { ExtensionError } = ExtensionUtils; + +/** + * Represents (in the main browser process) a user script. + * + * @param {UserScriptOptions} details + * The options object related to the user script + * (which has the properties described in the user_scripts.json + * JSON API schema file). + */ +class UserScriptParent { + constructor(details) { + this.scriptId = details.scriptId; + this.options = this._convertOptions(details); + } + + destroy() { + if (this.destroyed) { + throw new Error("Unable to destroy UserScriptParent twice"); + } + + this.destroyed = true; + this.options = null; + } + + _convertOptions(details) { + const options = { + matches: details.matches, + excludeMatches: details.excludeMatches, + includeGlobs: details.includeGlobs, + excludeGlobs: details.excludeGlobs, + allFrames: details.allFrames, + matchAboutBlank: details.matchAboutBlank, + runAt: details.runAt || "document_idle", + jsPaths: details.js, + userScriptOptions: { + scriptMetadata: details.scriptMetadata, + }, + originAttributesPatterns: null, + }; + + if (details.cookieStoreId != null) { + const cookieStoreIds = Array.isArray(details.cookieStoreId) + ? details.cookieStoreId + : [details.cookieStoreId]; + options.originAttributesPatterns = cookieStoreIds.map(cookieStoreId => + getOriginAttributesPatternForCookieStoreId(cookieStoreId) + ); + } + + return options; + } + + serialize() { + return this.options; + } +} + +this.userScripts = class extends ExtensionAPI { + constructor(...args) { + super(...args); + + // Map UserScriptParent> + this.userScriptsMap = new Map(); + } + + getAPI(context) { + const { extension } = context; + + // Set of the scriptIds registered from this context. + const registeredScriptIds = new Set(); + + const unregisterContentScripts = scriptIds => { + if (scriptIds.length === 0) { + return Promise.resolve(); + } + + for (let scriptId of scriptIds) { + registeredScriptIds.delete(scriptId); + extension.registeredContentScripts.delete(scriptId); + this.userScriptsMap.delete(scriptId); + } + extension.updateContentScripts(); + + return context.extension.broadcast("Extension:UnregisterContentScripts", { + id: context.extension.id, + scriptIds, + }); + }; + + // Unregister all the scriptId related to a context when it is closed, + // and revoke all the created blob urls once the context is destroyed. + context.callOnClose({ + close() { + unregisterContentScripts(Array.from(registeredScriptIds)); + }, + }); + + return { + userScripts: { + register: async details => { + for (let origin of details.matches) { + if (!extension.allowedOrigins.subsumes(new MatchPattern(origin))) { + throw new ExtensionError( + `Permission denied to register a user script for ${origin}` + ); + } + } + + const userScript = new UserScriptParent(details); + const { scriptId } = userScript; + + this.userScriptsMap.set(scriptId, userScript); + registeredScriptIds.add(scriptId); + + const scriptOptions = userScript.serialize(); + + extension.registeredContentScripts.set(scriptId, scriptOptions); + extension.updateContentScripts(); + + await extension.broadcast("Extension:RegisterContentScripts", { + id: extension.id, + scripts: [{ scriptId, options: scriptOptions }], + }); + + return scriptId; + }, + + // This method is not available to the extension code, the extension code + // doesn't have access to the internally used scriptId, on the contrary + // the extension code will call script.unregister on the script API object + // that is resolved from the register API method returned promise. + unregister: async scriptId => { + const userScript = this.userScriptsMap.get(scriptId); + if (!userScript) { + throw new Error(`No such user script ID: ${scriptId}`); + } + + userScript.destroy(); + + await unregisterContentScripts([scriptId]); + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-webNavigation.js b/toolkit/components/extensions/parent/ext-webNavigation.js new file mode 100644 index 0000000000..c65b61041b --- /dev/null +++ b/toolkit/components/extensions/parent/ext-webNavigation.js @@ -0,0 +1,276 @@ +/* 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"; + +// This file expects tabTracker to be defined in the global scope (e.g. +// by ext-browser.js or ext-android.js). +/* global tabTracker */ + +ChromeUtils.defineESModuleGetters(this, { + MatchURLFilters: "resource://gre/modules/MatchURLFilters.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + WebNavigation: "resource://gre/modules/WebNavigation.sys.mjs", + WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.sys.mjs", +}); + +var { ExtensionError } = ExtensionUtils; + +const defaultTransitionTypes = { + topFrame: "link", + subFrame: "auto_subframe", +}; + +const frameTransitions = { + anyFrame: { + qualifiers: ["server_redirect", "client_redirect", "forward_back"], + }, + topFrame: { + types: ["reload", "form_submit"], + }, +}; + +const tabTransitions = { + topFrame: { + qualifiers: ["from_address_bar"], + types: ["auto_bookmark", "typed", "keyword", "generated", "link"], + }, + subFrame: { + types: ["manual_subframe"], + }, +}; + +const isTopLevelFrame = ({ frameId, parentFrameId }) => { + return frameId == 0 && parentFrameId == -1; +}; + +const fillTransitionProperties = (eventName, src, dst) => { + if ( + eventName == "onCommitted" || + eventName == "onHistoryStateUpdated" || + eventName == "onReferenceFragmentUpdated" + ) { + let frameTransitionData = src.frameTransitionData || {}; + let tabTransitionData = src.tabTransitionData || {}; + + let transitionType, + transitionQualifiers = []; + + // Fill transition properties for any frame. + for (let qualifier of frameTransitions.anyFrame.qualifiers) { + if (frameTransitionData[qualifier]) { + transitionQualifiers.push(qualifier); + } + } + + if (isTopLevelFrame(dst)) { + for (let type of frameTransitions.topFrame.types) { + if (frameTransitionData[type]) { + transitionType = type; + } + } + + for (let qualifier of tabTransitions.topFrame.qualifiers) { + if (tabTransitionData[qualifier]) { + transitionQualifiers.push(qualifier); + } + } + + for (let type of tabTransitions.topFrame.types) { + if (tabTransitionData[type]) { + transitionType = type; + } + } + + // If transitionType is not defined, defaults it to "link". + if (!transitionType) { + transitionType = defaultTransitionTypes.topFrame; + } + } else { + // If it is sub-frame, transitionType defaults it to "auto_subframe", + // "manual_subframe" is set only in case of a recent user interaction. + transitionType = tabTransitionData.link + ? "manual_subframe" + : defaultTransitionTypes.subFrame; + } + + // Fill the transition properties in the webNavigation event object. + dst.transitionType = transitionType; + dst.transitionQualifiers = transitionQualifiers; + } +}; + +this.webNavigation = class extends ExtensionAPIPersistent { + makeEventHandler(event) { + let { extension } = this; + let { tabManager } = extension; + return ({ fire }, params) => { + // Don't create a MatchURLFilters instance if the listener does not include any filter. + let [urlFilters] = params; + let filters = urlFilters ? new MatchURLFilters(urlFilters.url) : null; + + let listener = data => { + if (!data.browser) { + return; + } + if ( + !extension.privateBrowsingAllowed && + PrivateBrowsingUtils.isBrowserPrivate(data.browser) + ) { + return; + } + if (filters && !filters.matches(data.url)) { + return; + } + + let data2 = { + url: data.url, + timeStamp: Date.now(), + }; + + if (event == "onErrorOccurred") { + data2.error = data.error; + } + + if (data.frameId != undefined) { + data2.frameId = data.frameId; + data2.parentFrameId = data.parentFrameId; + } + + if (data.sourceFrameId != undefined) { + data2.sourceFrameId = data.sourceFrameId; + } + + // Do not send a webNavigation event when the data.browser is related to a tab from a + // new window opened to adopt an existent tab (See Bug 1443221 for a rationale). + const chromeWin = data.browser.ownerGlobal; + + if ( + chromeWin && + chromeWin.gBrowser && + chromeWin.gBrowserInit && + chromeWin.gBrowserInit.isAdoptingTab() && + chromeWin.gBrowser.selectedBrowser === data.browser + ) { + return; + } + + // Fills in tabId typically. + Object.assign(data2, tabTracker.getBrowserData(data.browser)); + if (data2.tabId < 0) { + return; + } + let tab = tabTracker.getTab(data2.tabId); + if (!tabManager.canAccessTab(tab)) { + return; + } + + if (data.sourceTabBrowser) { + data2.sourceTabId = tabTracker.getBrowserData( + data.sourceTabBrowser + ).tabId; + } + + fillTransitionProperties(event, data, data2); + + fire.async(data2); + }; + + WebNavigation[event].addListener(listener); + return { + unregister() { + WebNavigation[event].removeListener(listener); + }, + convert(_fire) { + fire = _fire; + }, + }; + }; + } + + makeEventManagerAPI(event, context) { + let self = this; + return new EventManager({ + context, + module: "webNavigation", + event, + register(fire, ...params) { + let fn = self.makeEventHandler(event); + return fn({ fire }, params).unregister; + }, + }).api(); + } + + PERSISTENT_EVENTS = { + onBeforeNavigate: this.makeEventHandler("onBeforeNavigate"), + onCommitted: this.makeEventHandler("onCommitted"), + onDOMContentLoaded: this.makeEventHandler("onDOMContentLoaded"), + onCompleted: this.makeEventHandler("onCompleted"), + onErrorOccurred: this.makeEventHandler("onErrorOccurred"), + onReferenceFragmentUpdated: this.makeEventHandler( + "onReferenceFragmentUpdated" + ), + onHistoryStateUpdated: this.makeEventHandler("onHistoryStateUpdated"), + onCreatedNavigationTarget: this.makeEventHandler( + "onCreatedNavigationTarget" + ), + }; + + getAPI(context) { + let { extension } = context; + let { tabManager } = extension; + + return { + webNavigation: { + // onTabReplaced does nothing, it exists for compat. + onTabReplaced: new EventManager({ + context, + name: "webNavigation.onTabReplaced", + register: fire => { + return () => {}; + }, + }).api(), + onBeforeNavigate: this.makeEventManagerAPI("onBeforeNavigate", context), + onCommitted: this.makeEventManagerAPI("onCommitted", context), + onDOMContentLoaded: this.makeEventManagerAPI( + "onDOMContentLoaded", + context + ), + onCompleted: this.makeEventManagerAPI("onCompleted", context), + onErrorOccurred: this.makeEventManagerAPI("onErrorOccurred", context), + onReferenceFragmentUpdated: this.makeEventManagerAPI( + "onReferenceFragmentUpdated", + context + ), + onHistoryStateUpdated: this.makeEventManagerAPI( + "onHistoryStateUpdated", + context + ), + onCreatedNavigationTarget: this.makeEventManagerAPI( + "onCreatedNavigationTarget", + context + ), + getAllFrames({ tabId }) { + let tab = tabManager.get(tabId); + if (tab.discarded) { + return null; + } + let frames = WebNavigationFrames.getAllFrames(tab.browsingContext); + return frames.map(fd => ({ tabId, ...fd })); + }, + getFrame({ tabId, frameId }) { + let tab = tabManager.get(tabId); + if (tab.discarded) { + return null; + } + let fd = WebNavigationFrames.getFrame(tab.browsingContext, frameId); + if (!fd) { + throw new ExtensionError(`No frame found with frameId: ${frameId}`); + } + return { tabId, ...fd }; + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/parent/ext-webRequest.js b/toolkit/components/extensions/parent/ext-webRequest.js new file mode 100644 index 0000000000..4f0ea90abd --- /dev/null +++ b/toolkit/components/extensions/parent/ext-webRequest.js @@ -0,0 +1,206 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + WebRequest: "resource://gre/modules/WebRequest.sys.mjs", +}); + +var { parseMatchPatterns } = ExtensionUtils; + +// The guts of a WebRequest event handler. Takes care of converting +// |details| parameter when invoking listeners. +function registerEvent( + extension, + eventName, + fire, + filter, + info, + remoteTab = null +) { + let listener = async data => { + let event = data.serialize(eventName); + if (data.registerTraceableChannel) { + // If this is a primed listener, no tabParent was passed in here, + // but the convert() callback later in this function will be called + // when the background page is started. Force that to happen here + // after which we'll have a valid tabParent. + if (fire.wakeup) { + await fire.wakeup(); + } + data.registerTraceableChannel(extension.policy, remoteTab); + } + + return fire.sync(event); + }; + + let filter2 = {}; + if (filter.urls) { + let perms = new MatchPatternSet([ + ...extension.allowedOrigins.patterns, + ...extension.optionalOrigins.patterns, + ]); + + filter2.urls = parseMatchPatterns(filter.urls); + + if (!perms.overlapsAll(filter2.urls)) { + Cu.reportError( + "The webRequest.addListener filter doesn't overlap with host permissions." + ); + } + } + if (filter.types) { + filter2.types = filter.types; + } + if (filter.tabId !== undefined) { + filter2.tabId = filter.tabId; + } + if (filter.windowId !== undefined) { + filter2.windowId = filter.windowId; + } + if (filter.incognito !== undefined) { + filter2.incognito = filter.incognito; + } + + let blockingAllowed = extension.hasPermission("webRequestBlocking"); + + let info2 = []; + if (info) { + for (let desc of info) { + if (desc == "blocking" && !blockingAllowed) { + // This is usually checked in the child process (based on the API schemas, where these options + // should be checked with the "webRequestBlockingPermissionRequired" postprocess property), + // but it is worth to also check it here just in case a new webRequest has been added and + // it has not yet using the expected postprocess property). + Cu.reportError( + "Using webRequest.addListener with the blocking option " + + "requires the 'webRequestBlocking' permission." + ); + } else { + info2.push(desc); + } + } + } + + let listenerDetails = { + addonId: extension.id, + policy: extension.policy, + blockingAllowed, + }; + WebRequest[eventName].addListener(listener, filter2, info2, listenerDetails); + + return { + unregister: () => { + WebRequest[eventName].removeListener(listener); + }, + convert(_fire, context) { + fire = _fire; + remoteTab = context.xulBrowser.frameLoader.remoteTab; + }, + }; +} + +function makeWebRequestEventAPI(context, event, extensionApi) { + return new EventManager({ + context, + module: "webRequest", + event, + extensionApi, + }).api(); +} + +function makeWebRequestEventRegistrar(event) { + return function ({ fire, context }, params) { + // ExtensionAPIPersistent makes sure this function will be bound + // to the ExtensionAPIPersistent instance. + const { extension } = this; + + const [filter, info] = params; + + // When we are registering the real listener coming from the extension context, + // we should get the additional remoteTab parameter value from the extension context + // (which is then used by the registerTraceableChannel helper to register stream + // filters to the channel and associate them to the extension context that has + // created it and will be handling the filter onstart/ondata/onend events). + let remoteTab; + if (context) { + remoteTab = context.xulBrowser.frameLoader.remoteTab; + } + + return registerEvent(extension, event, fire, filter, info, remoteTab); + }; +} + +this.webRequest = class extends ExtensionAPIPersistent { + primeListener(event, fire, params, isInStartup) { + // During early startup if the listener does not use blocking we do not prime it. + if (!isInStartup || params[1]?.includes("blocking")) { + return super.primeListener(event, fire, params, isInStartup); + } + } + + PERSISTENT_EVENTS = { + onBeforeRequest: makeWebRequestEventRegistrar("onBeforeRequest"), + onBeforeSendHeaders: makeWebRequestEventRegistrar("onBeforeSendHeaders"), + onSendHeaders: makeWebRequestEventRegistrar("onSendHeaders"), + onHeadersReceived: makeWebRequestEventRegistrar("onHeadersReceived"), + onAuthRequired: makeWebRequestEventRegistrar("onAuthRequired"), + onBeforeRedirect: makeWebRequestEventRegistrar("onBeforeRedirect"), + onResponseStarted: makeWebRequestEventRegistrar("onResponseStarted"), + onErrorOccurred: makeWebRequestEventRegistrar("onErrorOccurred"), + onCompleted: makeWebRequestEventRegistrar("onCompleted"), + }; + + getAPI(context) { + return { + webRequest: { + onBeforeRequest: makeWebRequestEventAPI( + context, + "onBeforeRequest", + this + ), + onBeforeSendHeaders: makeWebRequestEventAPI( + context, + "onBeforeSendHeaders", + this + ), + onSendHeaders: makeWebRequestEventAPI(context, "onSendHeaders", this), + onHeadersReceived: makeWebRequestEventAPI( + context, + "onHeadersReceived", + this + ), + onAuthRequired: makeWebRequestEventAPI(context, "onAuthRequired", this), + onBeforeRedirect: makeWebRequestEventAPI( + context, + "onBeforeRedirect", + this + ), + onResponseStarted: makeWebRequestEventAPI( + context, + "onResponseStarted", + this + ), + onErrorOccurred: makeWebRequestEventAPI( + context, + "onErrorOccurred", + this + ), + onCompleted: makeWebRequestEventAPI(context, "onCompleted", this), + getSecurityInfo: function (requestId, options = {}) { + return WebRequest.getSecurityInfo({ + id: requestId, + policy: context.extension.policy, + remoteTab: context.xulBrowser.frameLoader.remoteTab, + options, + }); + }, + handlerBehaviorChanged: function () { + // TODO: Flush all caches. + }, + }, + }; + } +}; diff --git a/toolkit/components/extensions/schemas/LICENSE-CHROMIUM b/toolkit/components/extensions/schemas/LICENSE-CHROMIUM new file mode 100644 index 0000000000..9314092fdc --- /dev/null +++ b/toolkit/components/extensions/schemas/LICENSE-CHROMIUM @@ -0,0 +1,27 @@ +// Copyright (c) 2006-2008 The Chromium Authors. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/toolkit/components/extensions/schemas/README.md b/toolkit/components/extensions/schemas/README.md new file mode 100644 index 0000000000..790fcd648e --- /dev/null +++ b/toolkit/components/extensions/schemas/README.md @@ -0,0 +1,13 @@ +This source code is available under the [Mozilla Public License 2.0](/LICENSE). + +Additionally, parts of the schema files originated from Chromium source code: + +> Copyright (c) 2012 The Chromium Authors. All rights reserved. +> Use of this source code is governed by a BSD-style license that can be +> found in the [LICENSE-CHROMIUM](LICENSE-CHROMIUM) file. + +You are not granted rights or licenses to the trademarks of the +Mozilla Foundation or any party, including without limitation the +Firefox name or logo. + +For more information, see: https://www.mozilla.org/foundation/licensing.html diff --git a/toolkit/components/extensions/schemas/activity_log.json b/toolkit/components/extensions/schemas/activity_log.json new file mode 100644 index 0000000000..7f60817539 --- /dev/null +++ b/toolkit/components/extensions/schemas/activity_log.json @@ -0,0 +1,101 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "PermissionPrivileged", + "choices": [ + { + "type": "string", + "enum": ["activityLog"] + } + ] + } + ] + }, + { + "namespace": "activityLog", + "description": "Monitor extension activity", + "permissions": ["activityLog"], + "events": [ + { + "name": "onExtensionActivity", + "description": "Receives an activityItem for each logging event.", + "type": "function", + "parameters": [ + { + "name": "details", + "type": "object", + "properties": { + "timeStamp": { + "$ref": "extensionTypes.Date", + "description": "The date string when this call is triggered." + }, + "type": { + "type": "string", + "enum": [ + "api_call", + "api_event", + "content_script", + "user_script" + ], + "description": "The type of log entry. api_call is a function call made by the extension and api_event is an event callback to the extension. content_script is logged when a content script is injected." + }, + "viewType": { + "type": "string", + "optional": true, + "enum": [ + "background", + "popup", + "sidebar", + "tab", + "devtools_page", + "devtools_panel" + ], + "description": "The type of view where the activity occurred. Content scripts will not have a viewType." + }, + "name": { + "type": "string", + "description": "The name of the api call or event, or the script url if this is a content or user script event." + }, + "data": { + "type": "object", + "properties": { + "args": { + "type": "array", + "optional": true, + "items": { + "type": "any" + }, + "description": "A list of arguments passed to the call." + }, + "result": { + "type": "object", + "optional": true, + "description": "The result of the call." + }, + "tabId": { + "type": "integer", + "optional": true, + "description": "The tab associated with this event if it is a tab or content script." + }, + "url": { + "type": "string", + "optional": true, + "description": "If the type is content_script, this is the url of the script that was injected." + } + } + } + } + } + ], + "extraParameters": [ + { + "name": "id", + "type": "string" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/alarms.json b/toolkit/components/extensions/schemas/alarms.json new file mode 100644 index 0000000000..852001aa6c --- /dev/null +++ b/toolkit/components/extensions/schemas/alarms.json @@ -0,0 +1,166 @@ +[ + { + "namespace": "alarms", + "permissions": ["alarms"], + "types": [ + { + "id": "Alarm", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of this alarm." + }, + "scheduledTime": { + "type": "number", + "description": "Time when the alarm is scheduled to fire, in milliseconds past the epoch." + }, + "periodInMinutes": { + "type": "number", + "optional": true, + "description": "When present, signals that the alarm triggers periodically after so many minutes." + } + } + } + ], + "functions": [ + { + "name": "create", + "type": "function", + "description": "Creates an alarm. After the delay is expired, the onAlarm event is fired. If there is another alarm with the same name (or no name if none is specified), it will be cancelled and replaced by this alarm.", + "parameters": [ + { + "type": "string", + "name": "name", + "optional": true, + "description": "Optional name to identify this alarm. Defaults to the empty string." + }, + { + "type": "object", + "name": "alarmInfo", + "description": "Details about the alarm. The alarm first fires either at 'when' milliseconds past the epoch (if 'when' is provided), after 'delayInMinutes' minutes from the current time (if 'delayInMinutes' is provided instead), or after 'periodInMinutes' minutes from the current time (if only 'periodInMinutes' is provided). Users should never provide both 'when' and 'delayInMinutes'. If 'periodInMinutes' is provided, then the alarm recurs repeatedly after that many minutes.", + "properties": { + "when": { + "type": "number", + "optional": true, + "description": "Time when the alarm is scheduled to first fire, in milliseconds past the epoch." + }, + "delayInMinutes": { + "type": "number", + "optional": true, + "description": "Number of minutes from the current time after which the alarm should first fire." + }, + "periodInMinutes": { + "type": "number", + "optional": true, + "description": "Number of minutes after which the alarm should recur repeatedly." + } + } + } + ] + }, + { + "name": "get", + "type": "function", + "description": "Retrieves details about the specified alarm.", + "async": "callback", + "parameters": [ + { + "type": "string", + "name": "name", + "optional": true, + "description": "The name of the alarm to get. Defaults to the empty string." + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "alarm", + "$ref": "Alarm", + "optional": true + } + ] + } + ] + }, + { + "name": "getAll", + "type": "function", + "description": "Gets an array of all the alarms.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "alarms", + "type": "array", + "items": { "$ref": "Alarm" } + } + ] + } + ] + }, + { + "name": "clear", + "type": "function", + "description": "Clears the alarm with the given name.", + "async": "callback", + "parameters": [ + { + "type": "string", + "name": "name", + "optional": true, + "description": "The name of the alarm to clear. Defaults to the empty string." + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "wasCleared", + "type": "boolean", + "description": "Whether an alarm of the given name was found to clear." + } + ] + } + ] + }, + { + "name": "clearAll", + "type": "function", + "description": "Clears all alarms.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "wasCleared", + "type": "boolean", + "description": "Whether any alarm was found to clear." + } + ] + } + ] + } + ], + "events": [ + { + "name": "onAlarm", + "type": "function", + "description": "Fired when an alarm has expired. Useful for transient background pages.", + "parameters": [ + { + "name": "name", + "$ref": "Alarm", + "description": "The alarm that has expired." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/browser_action.json b/toolkit/components/extensions/schemas/browser_action.json new file mode 100644 index 0000000000..9813d930f7 --- /dev/null +++ b/toolkit/components/extensions/schemas/browser_action.json @@ -0,0 +1,530 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "id": "ActionManifest", + "type": "object", + "additionalProperties": { "$ref": "UnrecognizedProperty" }, + "properties": { + "default_title": { + "type": "string", + "optional": true, + "preprocess": "localize" + }, + "default_icon": { + "$ref": "IconPath", + "optional": true + }, + "theme_icons": { + "type": "array", + "optional": true, + "minItems": 1, + "items": { "$ref": "ThemeIcons" }, + "description": "Specifies icons to use for dark and light themes" + }, + "default_popup": { + "type": "string", + "format": "relativeUrl", + "optional": true, + "preprocess": "localize" + }, + "browser_style": { + "type": "boolean", + "optional": true, + "description": "Deprecated in Manifest V3." + }, + "default_area": { + "description": "Defines the location the browserAction will appear by default. The default location is navbar.", + "type": "string", + "enum": ["navbar", "menupanel", "tabstrip", "personaltoolbar"], + "optional": true + } + } + }, + { + "$extend": "WebExtensionManifest", + "properties": { + "action": { + "min_manifest_version": 3, + "$ref": "ActionManifest", + "optional": true + } + } + }, + { + "$extend": "WebExtensionManifest", + "properties": { + "browser_action": { + "max_manifest_version": 2, + "$ref": "ActionManifest", + "optional": true + } + } + } + ] + }, + { + "namespace": "action", + "description": "Use browser actions to put icons in the main browser toolbar, to the right of the address bar. In addition to its icon, a browser action can also have a tooltip, a badge, and a popup.", + "permissions": ["manifest:action", "manifest:browser_action"], + "min_manifest_version": 3, + "types": [ + { + "id": "Details", + "type": "object", + "description": "Specifies to which tab or window the value should be set, or from which one it should be retrieved. If no tab nor window is specified, the global value is set or retrieved.", + "properties": { + "tabId": { + "type": "integer", + "optional": true, + "minimum": 0, + "description": "When setting a value, it will be specific to the specified tab, and will automatically reset when the tab navigates. When getting, specifies the tab to get the value from; if there is no tab-specific value, the window one will be inherited." + }, + "windowId": { + "type": "integer", + "optional": true, + "minimum": -2, + "description": "When setting a value, it will be specific to the specified window. When getting, specifies the window to get the value from; if there is no window-specific value, the global one will be inherited." + } + } + }, + { + "id": "ColorArray", + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "minItems": 4, + "maxItems": 4 + }, + { + "id": "ImageDataType", + "type": "object", + "isInstanceOf": "ImageData", + "additionalProperties": { "type": "any" }, + "postprocess": "convertImageDataToURL", + "description": "Pixel data for an image. Must be an ImageData object (for example, from a canvas element)." + }, + { + "id": "ColorValue", + "description": "An array of four integers in the range [0,255] that make up the RGBA color of the badge. For example, opaque red is [255, 0, 0, 255]. Can also be a string with a CSS value, with opaque red being #FF0000 or #F00.", + "choices": [ + { "type": "string" }, + { "$ref": "ColorArray" }, + { "type": "null" } + ] + }, + { + "id": "OnClickData", + "type": "object", + "description": "Information sent when a browser action is clicked.", + "properties": { + "modifiers": { + "type": "array", + "items": { + "type": "string", + "enum": ["Shift", "Alt", "Command", "Ctrl", "MacCtrl"] + }, + "description": "An array of keyboard modifiers that were held while the menu item was clicked." + }, + "button": { + "type": "integer", + "optional": true, + "description": "An integer value of button by which menu item was clicked." + } + } + } + ], + "functions": [ + { + "name": "setTitle", + "type": "function", + "description": "Sets the title of the browser action. This shows up in the tooltip.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "$import": "Details", + "properties": { + "title": { + "choices": [{ "type": "string" }, { "type": "null" }], + "description": "The string the browser action should display when moused over." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "getTitle", + "type": "function", + "description": "Gets the title of the browser action.", + "async": "callback", + "parameters": [ + { + "name": "details", + "$ref": "Details" + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "result", + "type": "string" + } + ] + } + ] + }, + { + "name": "getUserSettings", + "type": "function", + "description": "Returns the user-specified settings relating to an extension's action.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "userSettings", + "type": "object", + "properties": { + "isOnToolbar": { + "type": "boolean", + "optional": true, + "description": "Whether the extension's action icon is visible on browser windows' top-level toolbar (i.e., whether the extension has been 'pinned' by the user)." + } + }, + "description": "The collection of user-specified settings relating to an extension's action." + } + ] + } + ] + }, + { + "name": "setIcon", + "type": "function", + "description": "Sets the icon for the browser action. The icon can be specified either as the path to an image file or as the pixel data from a canvas element, or as dictionary of either one of those. Either the path or the imageData property must be specified.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "$import": "Details", + "properties": { + "imageData": { + "choices": [ + { "$ref": "ImageDataType" }, + { + "type": "object", + "patternProperties": { + "^[1-9]\\d*$": { "$ref": "ImageDataType" } + } + } + ], + "optional": true, + "description": "Either an ImageData object or a dictionary {size -> ImageData} representing icon to be set. If the icon is specified as a dictionary, the actual image to be used is chosen depending on screen's pixel density. If the number of image pixels that fit into one screen space unit equals scale, then image with size scale * 19 will be selected. Initially only scales 1 and 2 will be supported. At least one image must be specified. Note that 'details.imageData = foo' is equivalent to 'details.imageData = {'19': foo}'" + }, + "path": { + "choices": [ + { "type": "string" }, + { + "type": "object", + "patternProperties": { + "^[1-9]\\d*$": { "type": "string" } + } + } + ], + "optional": true, + "description": "Either a relative image path or a dictionary {size -> relative image path} pointing to icon to be set. If the icon is specified as a dictionary, the actual image to be used is chosen depending on screen's pixel density. If the number of image pixels that fit into one screen space unit equals scale, then image with size scale * 19 will be selected. Initially only scales 1 and 2 will be supported. At least one image must be specified. Note that 'details.path = foo' is equivalent to 'details.imageData = {'19': foo}'" + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "setPopup", + "type": "function", + "description": "Sets the html document to be opened as a popup when the user clicks on the browser action's icon.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "$import": "Details", + "properties": { + "popup": { + "choices": [{ "type": "string" }, { "type": "null" }], + "description": "The html file to show in a popup. If set to the empty string (''), no popup is shown." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "getPopup", + "type": "function", + "description": "Gets the html document set as the popup for this browser action.", + "async": "callback", + "parameters": [ + { + "name": "details", + "$ref": "Details" + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "result", + "type": "string" + } + ] + } + ] + }, + { + "name": "setBadgeText", + "type": "function", + "description": "Sets the badge text for the browser action. The badge is displayed on top of the icon.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "$import": "Details", + "properties": { + "text": { + "choices": [{ "type": "string" }, { "type": "null" }], + "description": "Any number of characters can be passed, but only about four can fit in the space." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "getBadgeText", + "type": "function", + "description": "Gets the badge text of the browser action. If no tab nor window is specified is specified, the global badge text is returned.", + "async": "callback", + "parameters": [ + { + "name": "details", + "$ref": "Details" + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "result", + "type": "string" + } + ] + } + ] + }, + { + "name": "setBadgeBackgroundColor", + "type": "function", + "description": "Sets the background color for the badge.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "$import": "Details", + "properties": { + "color": { "$ref": "ColorValue" } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "getBadgeBackgroundColor", + "type": "function", + "description": "Gets the background color of the browser action badge.", + "async": "callback", + "parameters": [ + { + "name": "details", + "$ref": "Details" + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "result", + "$ref": "ColorArray" + } + ] + } + ] + }, + { + "name": "setBadgeTextColor", + "type": "function", + "description": "Sets the text color for the badge.", + "async": true, + "parameters": [ + { + "name": "details", + "type": "object", + "$import": "Details", + "properties": { + "color": { "$ref": "ColorValue" } + } + } + ] + }, + { + "name": "getBadgeTextColor", + "type": "function", + "description": "Gets the text color of the browser action badge.", + "async": true, + "parameters": [ + { + "name": "details", + "$ref": "Details" + } + ] + }, + { + "name": "enable", + "type": "function", + "description": "Enables the browser action for a tab. By default, browser actions are enabled.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "optional": true, + "name": "tabId", + "minimum": 0, + "description": "The id of the tab for which you want to modify the browser action." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "disable", + "type": "function", + "description": "Disables the browser action for a tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "optional": true, + "name": "tabId", + "minimum": 0, + "description": "The id of the tab for which you want to modify the browser action." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "isEnabled", + "type": "function", + "description": "Checks whether the browser action is enabled.", + "async": true, + "parameters": [ + { + "name": "details", + "$ref": "Details" + } + ] + }, + { + "name": "openPopup", + "type": "function", + "description": "Opens the extension popup window in the specified window.", + "async": true, + "parameters": [ + { + "name": "options", + "optional": true, + "type": "object", + "description": "An object with information about the popup to open.", + "properties": { + "windowId": { + "type": "integer", + "minimum": -2, + "optional": true, + "description": "Defaults to the $(topic:current-window)[current window]." + } + } + } + ] + } + ], + "events": [ + { + "name": "onClicked", + "type": "function", + "description": "Fired when a browser action icon is clicked. This event will not fire if the browser action has a popup.", + "parameters": [ + { + "name": "tab", + "$ref": "tabs.Tab" + }, + { + "name": "info", + "$ref": "OnClickData", + "optional": true + } + ] + } + ] + }, + { + "namespace": "browserAction", + "permissions": ["manifest:action", "manifest:browser_action"], + "max_manifest_version": 2, + "$import": "action" + } +] diff --git a/toolkit/components/extensions/schemas/browser_settings.json b/toolkit/components/extensions/schemas/browser_settings.json new file mode 100644 index 0000000000..0a584d5c3b --- /dev/null +++ b/toolkit/components/extensions/schemas/browser_settings.json @@ -0,0 +1,135 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermission", + "choices": [ + { + "type": "string", + "enum": ["browserSettings"] + } + ] + } + ] + }, + { + "namespace": "browserSettings", + "description": "Use the browser.browserSettings API to control global settings of the browser.", + "permissions": ["browserSettings"], + "types": [ + { + "id": "ImageAnimationBehavior", + "type": "string", + "enum": ["normal", "none", "once"], + "description": "How images should be animated in the browser." + }, + { + "id": "ContextMenuMouseEvent", + "type": "string", + "enum": ["mouseup", "mousedown"], + "description": "After which mouse event context menus should popup." + }, + { + "id": "ColorManagementMode", + "type": "string", + "enum": ["off", "full", "tagged_only"], + "description": "Color management mode." + } + ], + "properties": { + "allowPopupsForUserEvents": { + "$ref": "types.Setting", + "description": "Allows or disallows pop-up windows from opening in response to user events." + }, + "cacheEnabled": { + "$ref": "types.Setting", + "description": "Enables or disables the browser cache." + }, + "closeTabsByDoubleClick": { + "$ref": "types.Setting", + "description": "This boolean setting controls whether the selected tab can be closed with a double click." + }, + "contextMenuShowEvent": { + "$ref": "types.Setting", + "description": "Controls after which mouse event context menus popup. This setting's value is of type ContextMenuMouseEvent, which has possible values of mouseup and mousedown." + }, + "ftpProtocolEnabled": { + "$ref": "types.Setting", + "description": "Returns whether the FTP protocol is enabled. Read-only.", + "deprecated": "FTP support was removed from Firefox in bug 1574475" + }, + "homepageOverride": { + "$ref": "types.Setting", + "description": "Returns the value of the overridden home page. Read-only." + }, + "imageAnimationBehavior": { + "$ref": "types.Setting", + "description": "Controls the behaviour of image animation in the browser. This setting's value is of type ImageAnimationBehavior, defaulting to normal." + }, + "newTabPageOverride": { + "$ref": "types.Setting", + "description": "Returns the value of the overridden new tab page. Read-only." + }, + "newTabPosition": { + "$ref": "types.Setting", + "description": "Controls where new tabs are opened. `afterCurrent` will open all new tabs next to the current tab, `relatedAfterCurrent` will open only related tabs next to the current tab, and `atEnd` will open all tabs at the end of the tab strip. The default is `relatedAfterCurrent`." + }, + "openBookmarksInNewTabs": { + "$ref": "types.Setting", + "description": "This boolean setting controls whether bookmarks are opened in the current tab or in a new tab." + }, + "openSearchResultsInNewTabs": { + "$ref": "types.Setting", + "description": "This boolean setting controls whether search results are opened in the current tab or in a new tab." + }, + "openUrlbarResultsInNewTabs": { + "$ref": "types.Setting", + "description": "This boolean setting controls whether urlbar results are opened in the current tab or in a new tab." + }, + "webNotificationsDisabled": { + "$ref": "types.Setting", + "description": "Disables webAPI notifications." + }, + "overrideDocumentColors": { + "$ref": "types.Setting", + "description": "This setting controls whether the user-chosen colors override the page's colors." + }, + "overrideContentColorScheme": { + "$ref": "types.Setting", + "description": "This setting controls whether a light or dark color scheme overrides the page's preferred color scheme." + }, + "useDocumentFonts": { + "$ref": "types.Setting", + "description": "This setting controls whether the document's fonts are used." + }, + "zoomFullPage": { + "$ref": "types.Setting", + "description": "This boolean setting controls whether zoom is applied to the full page or to text only." + }, + "zoomSiteSpecific": { + "$ref": "types.Setting", + "description": "This boolean setting controls whether zoom is applied on a per-site basis or to the current tab only. If privacy.resistFingerprinting is true, this setting has no effect and zoom is applied to the current tab only." + } + } + }, + { + "namespace": "browserSettings.colorManagement", + "description": "Use the browserSettings.colorManagement API to query and set items related to color management.", + "permissions": ["browserSettings"], + "properties": { + "mode": { + "$ref": "types.Setting", + "description": "This setting controls the mode used for color management and must be a string from $(ref:browserSettings.ColorManagementMode)" + }, + "useNativeSRGB": { + "$ref": "types.Setting", + "description": "This boolean setting controls whether or not native sRGB color management is used." + }, + "useWebRenderCompositor": { + "$ref": "types.Setting", + "description": "This boolean setting controls whether or not the WebRender compositor is used." + } + } + } +] diff --git a/toolkit/components/extensions/schemas/browsing_data.json b/toolkit/components/extensions/schemas/browsing_data.json new file mode 100644 index 0000000000..ca6754aec3 --- /dev/null +++ b/toolkit/components/extensions/schemas/browsing_data.json @@ -0,0 +1,419 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermission", + "choices": [ + { + "type": "string", + "enum": ["browsingData"] + } + ] + } + ] + }, + { + "namespace": "browsingData", + "description": "Use the chrome.browsingData API to remove browsing data from a user's local profile.", + "permissions": ["browsingData"], + "types": [ + { + "id": "RemovalOptions", + "type": "object", + "description": "Options that determine exactly what data will be removed.", + "properties": { + "since": { + "$ref": "extensionTypes.Date", + "optional": true, + "description": "Remove data accumulated on or after this date, represented in milliseconds since the epoch (accessible via the getTime method of the JavaScript Date object). If absent, defaults to 0 (which would remove all browsing data)." + }, + "hostnames": { + "type": "array", + "items": { "type": "string", "format": "hostname" }, + "optional": true, + "description": "Only remove data associated with these hostnames (only applies to cookies and localStorage)." + }, + "cookieStoreId": { + "type": "string", + "description": "Only remove data associated with this specific cookieStoreId.", + "optional": true + }, + "originTypes": { + "type": "object", + "optional": true, + "description": "An object whose properties specify which origin types ought to be cleared. If this object isn't specified, it defaults to clearing only \"unprotected\" origins. Please ensure that you really want to remove application data before adding 'protectedWeb' or 'extensions'.", + "properties": { + "unprotectedWeb": { + "type": "boolean", + "optional": true, + "description": "Normal websites." + }, + "protectedWeb": { + "type": "boolean", + "optional": true, + "description": "Websites that have been installed as hosted applications (be careful!)." + }, + "extension": { + "type": "boolean", + "optional": true, + "description": "Extensions and packaged applications a user has installed (be _really_ careful!)." + } + } + } + } + }, + { + "id": "DataTypeSet", + "type": "object", + "description": "A set of data types. Missing data types are interpreted as false.", + "properties": { + "cache": { + "type": "boolean", + "optional": true, + "description": "The browser's cache. Note: when removing data, this clears the entire cache: it is not limited to the range you specify." + }, + "cookies": { + "type": "boolean", + "optional": true, + "description": "The browser's cookies." + }, + "downloads": { + "type": "boolean", + "optional": true, + "description": "The browser's download list." + }, + "formData": { + "type": "boolean", + "optional": true, + "description": "The browser's stored form data." + }, + "history": { + "type": "boolean", + "optional": true, + "description": "The browser's history." + }, + "indexedDB": { + "type": "boolean", + "optional": true, + "description": "Websites' IndexedDB data." + }, + "localStorage": { + "type": "boolean", + "optional": true, + "description": "Websites' local storage data." + }, + "serverBoundCertificates": { + "type": "boolean", + "optional": true, + "description": "Server-bound certificates." + }, + "passwords": { + "type": "boolean", + "optional": true, + "description": "Stored passwords." + }, + "pluginData": { + "type": "boolean", + "optional": true, + "description": "Plugins' data." + }, + "serviceWorkers": { + "type": "boolean", + "optional": true, + "description": "Service Workers." + } + } + } + ], + "functions": [ + { + "name": "settings", + "description": "Reports which types of data are currently selected in the 'Clear browsing data' settings UI. Note: some of the data types included in this API are not available in the settings UI, and some UI settings control more than one data type listed here.", + "type": "function", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "result", + "type": "object", + "properties": { + "options": { + "$ref": "RemovalOptions" + }, + "dataToRemove": { + "$ref": "DataTypeSet", + "description": "All of the types will be present in the result, with values of true if they are both selected to be removed and permitted to be removed, otherwise false." + }, + "dataRemovalPermitted": { + "$ref": "DataTypeSet", + "description": "All of the types will be present in the result, with values of true if they are permitted to be removed (e.g., by enterprise policy) and false if not." + } + } + } + ] + } + ] + }, + { + "name": "remove", + "description": "Clears various types of browsing data stored in a user's profile.", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "dataToRemove", + "$ref": "DataTypeSet", + "description": "The set of data types to remove." + }, + { + "name": "callback", + "type": "function", + "description": "Called when deletion has completed.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeAppcache", + "description": "Clears websites' appcache data.", + "type": "function", + "async": "callback", + "unsupported": true, + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when websites' appcache data has been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeCache", + "description": "Clears the browser's cache.", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when the browser's cache has been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeCookies", + "description": "Clears the browser's cookies and server-bound certificates modified within a particular timeframe.", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when the browser's cookies and server-bound certificates have been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeDownloads", + "description": "Clears the browser's list of downloaded files (not the downloaded files themselves).", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when the browser's list of downloaded files has been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeFileSystems", + "description": "Clears websites' file system data.", + "type": "function", + "async": "callback", + "unsupported": true, + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when websites' file systems have been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeFormData", + "description": "Clears the browser's stored form data (autofill).", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when the browser's form data has been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeHistory", + "description": "Clears the browser's history.", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when the browser's history has cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeIndexedDB", + "description": "Clears websites' IndexedDB data.", + "type": "function", + "async": "callback", + "unsupported": true, + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when websites' IndexedDB data has been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeLocalStorage", + "description": "Clears websites' local storage data.", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when websites' local storage has been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removePluginData", + "description": "Clears plugins' data.", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when plugins' data has been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removePasswords", + "description": "Clears the browser's stored passwords.", + "type": "function", + "async": "callback", + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when the browser's passwords have been cleared.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "removeWebSQL", + "description": "Clears websites' WebSQL data.", + "type": "function", + "async": "callback", + "unsupported": true, + "parameters": [ + { + "$ref": "RemovalOptions", + "name": "options" + }, + { + "name": "callback", + "type": "function", + "description": "Called when websites' WebSQL databases have been cleared.", + "optional": true, + "parameters": [] + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/captive_portal.json b/toolkit/components/extensions/schemas/captive_portal.json new file mode 100644 index 0000000000..fd697c66ae --- /dev/null +++ b/toolkit/components/extensions/schemas/captive_portal.json @@ -0,0 +1,80 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "PermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["captivePortal"] + } + ] + } + ] + }, + { + "namespace": "captivePortal", + "description": "This API provides the ability detect the captive portal state of the users connection.", + "permissions": ["captivePortal"], + "properties": { + "canonicalURL": { + "$ref": "types.Setting", + "description": "Return the canonical captive-portal detection URL. Read-only." + } + }, + "functions": [ + { + "name": "getState", + "type": "function", + "description": "Returns the current portal state, one of `unknown`, `not_captive`, `unlocked_portal`, `locked_portal`.", + "async": true, + "parameters": [] + }, + { + "name": "getLastChecked", + "type": "function", + "description": "Returns the time difference between NOW and the last time a request was completed in milliseconds.", + "async": true, + "parameters": [] + } + ], + "events": [ + { + "name": "onStateChanged", + "type": "function", + "description": "Fired when the captive portal state changes.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "state": { + "type": "string", + "enum": [ + "unknown", + "not_captive", + "unlocked_portal", + "locked_portal" + ], + "description": "The current captive portal state." + } + } + } + ] + }, + { + "name": "onConnectivityAvailable", + "type": "function", + "description": "This notification will be emitted when the captive portal service has determined that we can connect to the internet. The service will pass either `captive` if there is an unlocked captive portal present, or `clear` if no captive portal was detected.", + "parameters": [ + { + "name": "status", + "enum": ["captive", "clear"], + "type": "string" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/clipboard.json b/toolkit/components/extensions/schemas/clipboard.json new file mode 100644 index 0000000000..010a003c4c --- /dev/null +++ b/toolkit/components/extensions/schemas/clipboard.json @@ -0,0 +1,30 @@ +[ + { + "namespace": "clipboard", + "description": "Offers the ability to write to the clipboard. Reading is not supported because the clipboard can already be read through the standard web platform APIs.", + "permissions": ["clipboardWrite"], + "functions": [ + { + "name": "setImageData", + "type": "function", + "description": "Copy an image to the clipboard. The image is re-encoded before it is written to the clipboard. If the image is invalid, the clipboard is not modified.", + "async": true, + "parameters": [ + { + "type": "object", + "isInstanceOf": "ArrayBuffer", + "additionalProperties": true, + "name": "imageData", + "description": "The image data to be copied." + }, + { + "type": "string", + "name": "imageType", + "enum": ["jpeg", "png"], + "description": "The type of imageData." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/content_scripts.json b/toolkit/components/extensions/schemas/content_scripts.json new file mode 100644 index 0000000000..a45dc30918 --- /dev/null +++ b/toolkit/components/extensions/schemas/content_scripts.json @@ -0,0 +1,106 @@ +[ + { + "namespace": "contentScripts", + "max_manifest_version": 2, + "types": [ + { + "id": "RegisteredContentScriptOptions", + "type": "object", + "description": "Details of a content script registered programmatically", + "properties": { + "matches": { + "type": "array", + "optional": false, + "minItems": 1, + "items": { "$ref": "manifest.MatchPattern" } + }, + "excludeMatches": { + "type": "array", + "optional": true, + "minItems": 1, + "items": { "$ref": "manifest.MatchPattern" } + }, + "includeGlobs": { + "type": "array", + "optional": true, + "items": { "type": "string" } + }, + "excludeGlobs": { + "type": "array", + "optional": true, + "items": { "type": "string" } + }, + "css": { + "type": "array", + "optional": true, + "description": "The list of CSS files to inject", + "items": { "$ref": "extensionTypes.ExtensionFileOrCode" } + }, + "js": { + "type": "array", + "optional": true, + "description": "The list of JS files to inject", + "items": { "$ref": "extensionTypes.ExtensionFileOrCode" } + }, + "allFrames": { + "type": "boolean", + "optional": true, + "description": "If allFrames is true, implies that the JavaScript or CSS should be injected into all frames of current page. By default, it's false and is only injected into the top frame." + }, + "matchAboutBlank": { + "type": "boolean", + "optional": true, + "description": "If matchAboutBlank is true, then the code is also injected in about:blank and about:srcdoc frames if your extension has access to its parent document. Code cannot be inserted in top-level about:-frames. By default it is false." + }, + "runAt": { + "$ref": "extensionTypes.RunAt", + "optional": true, + "description": "The soonest that the JavaScript or CSS will be injected into the tab. Defaults to \"document_idle\"." + }, + "cookieStoreId": { + "choices": [ + { + "type": "array", + "minItems": 1, + "items": { "type": "string" } + }, + { + "type": "string" + } + ], + "optional": true, + "description": "limit the set of matched tabs to those that belong to the given cookie store id" + } + } + }, + { + "id": "RegisteredContentScript", + "type": "object", + "description": "An object that represents a content script registered programmatically", + "functions": [ + { + "name": "unregister", + "type": "function", + "description": "Unregister a content script registered programmatically", + "async": true, + "parameters": [] + } + ] + } + ], + "functions": [ + { + "name": "register", + "type": "function", + "description": "Register a content script programmatically", + "async": true, + "parameters": [ + { + "name": "contentScriptOptions", + "$ref": "RegisteredContentScriptOptions" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/contextual_identities.json b/toolkit/components/extensions/schemas/contextual_identities.json new file mode 100644 index 0000000000..315270ef20 --- /dev/null +++ b/toolkit/components/extensions/schemas/contextual_identities.json @@ -0,0 +1,241 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "PermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["contextualIdentities"] + } + ] + } + ] + }, + { + "namespace": "contextualIdentities", + "description": "Use the browser.contextualIdentities API to query and modify contextual identity, also called as containers.", + "permissions": ["contextualIdentities"], + "types": [ + { + "id": "ContextualIdentity", + "type": "object", + "description": "Represents information about a contextual identity.", + "properties": { + "name": { + "type": "string", + "description": "The name of the contextual identity." + }, + "icon": { + "type": "string", + "description": "The icon name of the contextual identity." + }, + "iconUrl": { + "type": "string", + "description": "The icon url of the contextual identity." + }, + "color": { + "type": "string", + "description": "The color name of the contextual identity." + }, + "colorCode": { + "type": "string", + "description": "The color hash of the contextual identity." + }, + "cookieStoreId": { + "type": "string", + "description": "The cookie store ID of the contextual identity." + } + } + } + ], + "functions": [ + { + "name": "get", + "type": "function", + "description": "Retrieves information about a single contextual identity.", + "async": true, + "parameters": [ + { + "type": "string", + "name": "cookieStoreId", + "description": "The ID of the contextual identity cookie store. " + } + ] + }, + { + "name": "query", + "type": "function", + "description": "Retrieves all contextual identities", + "async": true, + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Information to filter the contextual identities being retrieved.", + "properties": { + "name": { + "type": "string", + "optional": true, + "description": "Filters the contextual identity by name." + } + } + } + ] + }, + { + "name": "create", + "type": "function", + "description": "Creates a contextual identity with the given data.", + "async": true, + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Details about the contextual identity being created.", + "properties": { + "name": { + "type": "string", + "optional": false, + "description": "The name of the contextual identity." + }, + "color": { + "type": "string", + "optional": false, + "description": "The color of the contextual identity." + }, + "icon": { + "type": "string", + "optional": false, + "description": "The icon of the contextual identity." + } + } + } + ] + }, + { + "name": "update", + "type": "function", + "description": "Updates a contextual identity with the given data.", + "async": true, + "parameters": [ + { + "type": "string", + "name": "cookieStoreId", + "description": "The ID of the contextual identity cookie store. " + }, + { + "type": "object", + "name": "details", + "description": "Details about the contextual identity being created.", + "properties": { + "name": { + "type": "string", + "optional": true, + "description": "The name of the contextual identity." + }, + "color": { + "type": "string", + "optional": true, + "description": "The color of the contextual identity." + }, + "icon": { + "type": "string", + "optional": true, + "description": "The icon of the contextual identity." + } + } + } + ] + }, + { + "name": "move", + "type": "function", + "description": "Reorder one or more contextual identities by their cookieStoreIDs to a given position.", + "async": true, + "parameters": [ + { + "name": "cookieStoreIds", + "description": "The ID or list of IDs of the contextual identity cookie stores. ", + "choices": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + }, + { + "type": "integer", + "name": "position", + "description": "The position the contextual identity should move to." + } + ] + }, + { + "name": "remove", + "type": "function", + "description": "Deletes a contextual identity by its cookie Store ID.", + "async": true, + "parameters": [ + { + "type": "string", + "name": "cookieStoreId", + "description": "The ID of the contextual identity cookie store. " + } + ] + } + ], + "events": [ + { + "name": "onUpdated", + "type": "function", + "description": "Fired when a container is updated.", + "parameters": [ + { + "type": "object", + "name": "changeInfo", + "properties": { + "contextualIdentity": { + "$ref": "ContextualIdentity", + "description": "Contextual identity that has been updated" + } + } + } + ] + }, + { + "name": "onCreated", + "type": "function", + "description": "Fired when a new container is created.", + "parameters": [ + { + "type": "object", + "name": "changeInfo", + "properties": { + "contextualIdentity": { + "$ref": "ContextualIdentity", + "description": "Contextual identity that has been created" + } + } + } + ] + }, + { + "name": "onRemoved", + "type": "function", + "description": "Fired when a container is removed.", + "parameters": [ + { + "type": "object", + "name": "changeInfo", + "properties": { + "contextualIdentity": { + "$ref": "ContextualIdentity", + "description": "Contextual identity that has been removed" + } + } + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/cookies.json b/toolkit/components/extensions/schemas/cookies.json new file mode 100644 index 0000000000..2706cbde3d --- /dev/null +++ b/toolkit/components/extensions/schemas/cookies.json @@ -0,0 +1,467 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["cookies"] + } + ] + } + ] + }, + { + "namespace": "cookies", + "description": "Use the browser.cookies API to query and modify cookies, and to be notified when they change.", + "permissions": ["cookies"], + "types": [ + { + "id": "SameSiteStatus", + "type": "string", + "enum": ["no_restriction", "lax", "strict"], + "description": "A cookie's 'SameSite' state (https://tools.ietf.org/html/draft-west-first-party-cookies). 'no_restriction' corresponds to a cookie set without a 'SameSite' attribute, 'lax' to 'SameSite=Lax', and 'strict' to 'SameSite=Strict'." + }, + { + "id": "PartitionKey", + "type": "object", + "description": "The description of the storage partition of a cookie. This object may be omitted (null) if a cookie is not partitioned.", + "properties": { + "topLevelSite": { + "type": "string", + "optional": true, + "description": "The first-party URL of the cookie, if the cookie is in storage partitioned by the top-level site." + } + } + }, + { + "id": "Cookie", + "type": "object", + "description": "Represents information about an HTTP cookie.", + "properties": { + "name": { + "type": "string", + "description": "The name of the cookie." + }, + "value": { + "type": "string", + "description": "The value of the cookie." + }, + "domain": { + "type": "string", + "description": "The domain of the cookie (e.g. \"www.google.com\", \"example.com\")." + }, + "hostOnly": { + "type": "boolean", + "description": "True if the cookie is a host-only cookie (i.e. a request's host must exactly match the domain of the cookie)." + }, + "path": { + "type": "string", + "description": "The path of the cookie." + }, + "secure": { + "type": "boolean", + "description": "True if the cookie is marked as Secure (i.e. its scope is limited to secure channels, typically HTTPS)." + }, + "httpOnly": { + "type": "boolean", + "description": "True if the cookie is marked as HttpOnly (i.e. the cookie is inaccessible to client-side scripts)." + }, + "sameSite": { + "$ref": "SameSiteStatus", + "description": "The cookie's same-site status (i.e. whether the cookie is sent with cross-site requests)." + }, + "session": { + "type": "boolean", + "description": "True if the cookie is a session cookie, as opposed to a persistent cookie with an expiration date." + }, + "expirationDate": { + "type": "number", + "optional": true, + "description": "The expiration date of the cookie as the number of seconds since the UNIX epoch. Not provided for session cookies." + }, + "storeId": { + "type": "string", + "description": "The ID of the cookie store containing this cookie, as provided in getAllCookieStores()." + }, + "firstPartyDomain": { + "type": "string", + "description": "The first-party domain of the cookie." + }, + "partitionKey": { + "$ref": "PartitionKey", + "optional": true, + "description": "The cookie's storage partition, if any. null if not partitioned." + } + } + }, + { + "id": "CookieStore", + "type": "object", + "description": "Represents a cookie store in the browser. An incognito mode window, for instance, uses a separate cookie store from a non-incognito window.", + "properties": { + "id": { + "type": "string", + "description": "The unique identifier for the cookie store." + }, + "tabIds": { + "type": "array", + "items": { "type": "integer" }, + "description": "Identifiers of all the browser tabs that share this cookie store." + }, + "incognito": { + "type": "boolean", + "description": "Indicates if this is an incognito cookie store" + } + } + }, + { + "id": "OnChangedCause", + "type": "string", + "enum": [ + "evicted", + "expired", + "explicit", + "expired_overwrite", + "overwrite" + ], + "description": "The underlying reason behind the cookie's change. If a cookie was inserted, or removed via an explicit call to $(ref:cookies.remove), \"cause\" will be \"explicit\". If a cookie was automatically removed due to expiry, \"cause\" will be \"expired\". If a cookie was removed due to being overwritten with an already-expired expiration date, \"cause\" will be set to \"expired_overwrite\". If a cookie was automatically removed due to garbage collection, \"cause\" will be \"evicted\". If a cookie was automatically removed due to a \"set\" call that overwrote it, \"cause\" will be \"overwrite\". Plan your response accordingly." + } + ], + "functions": [ + { + "name": "get", + "type": "function", + "description": "Retrieves information about a single cookie. If more than one cookie of the same name exists for the given URL, the one with the longest path will be returned. For cookies with the same path length, the cookie with the earliest creation time will be returned.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Details to identify the cookie being retrieved.", + "properties": { + "url": { + "type": "string", + "description": "The URL with which the cookie to retrieve is associated. This argument may be a full URL, in which case any data following the URL path (e.g. the query string) is simply ignored. If host permissions for this URL are not specified in the manifest file, the API call will fail." + }, + "name": { + "type": "string", + "description": "The name of the cookie to retrieve." + }, + "storeId": { + "type": "string", + "optional": true, + "description": "The ID of the cookie store in which to look for the cookie. By default, the current execution context's cookie store will be used." + }, + "firstPartyDomain": { + "type": "string", + "optional": true, + "description": "The first-party domain which the cookie to retrieve is associated. This attribute is required if First-Party Isolation is enabled." + }, + "partitionKey": { + "$ref": "PartitionKey", + "optional": true, + "description": "The storage partition, if the cookie is part of partitioned storage. By default, only non-partitioned cookies are returned." + } + } + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "cookie", + "$ref": "Cookie", + "optional": true, + "description": "Contains details about the cookie. This parameter is null if no such cookie was found." + } + ] + } + ] + }, + { + "name": "getAll", + "type": "function", + "description": "Retrieves all cookies from a single cookie store that match the given information. The cookies returned will be sorted, with those with the longest path first. If multiple cookies have the same path length, those with the earliest creation time will be first.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Information to filter the cookies being retrieved.", + "properties": { + "url": { + "type": "string", + "optional": true, + "description": "Restricts the retrieved cookies to those that would match the given URL." + }, + "name": { + "type": "string", + "optional": true, + "description": "Filters the cookies by name." + }, + "domain": { + "type": "string", + "optional": true, + "description": "Restricts the retrieved cookies to those whose domains match or are subdomains of this one." + }, + "path": { + "type": "string", + "optional": true, + "description": "Restricts the retrieved cookies to those whose path exactly matches this string." + }, + "secure": { + "type": "boolean", + "optional": true, + "description": "Filters the cookies by their Secure property." + }, + "session": { + "type": "boolean", + "optional": true, + "description": "Filters out session vs. persistent cookies." + }, + "storeId": { + "type": "string", + "optional": true, + "description": "The cookie store to retrieve cookies from. If omitted, the current execution context's cookie store will be used." + }, + "firstPartyDomain": { + "type": "string", + "optional": "omit-key-if-missing", + "description": "Restricts the retrieved cookies to those whose first-party domains match this one. This attribute is required if First-Party Isolation is enabled. To not filter by a specific first-party domain, use `null` or `undefined`." + }, + "partitionKey": { + "$ref": "PartitionKey", + "optional": true, + "description": "Selects a specific storage partition to look up cookies. Defaults to null, in which case only non-partitioned cookies are retrieved. If an object iis passed, partitioned cookies are also included, and filtered based on the keys present in the given PartitionKey description. An empty object ({}) returns all cookies (partitioned + unpartitioned), a non-empty object (e.g. {topLevelSite: '...'}) only returns cookies whose partition match all given attributes." + } + } + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "cookies", + "type": "array", + "items": { "$ref": "Cookie" }, + "description": "All the existing, unexpired cookies that match the given cookie info." + } + ] + } + ] + }, + { + "name": "set", + "type": "function", + "description": "Sets a cookie with the given cookie data; may overwrite equivalent cookies if they exist.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Details about the cookie being set.", + "properties": { + "url": { + "type": "string", + "description": "The request-URI to associate with the setting of the cookie. This value can affect the default domain and path values of the created cookie. If host permissions for this URL are not specified in the manifest file, the API call will fail." + }, + "name": { + "type": "string", + "optional": true, + "description": "The name of the cookie. Empty by default if omitted." + }, + "value": { + "type": "string", + "optional": true, + "description": "The value of the cookie. Empty by default if omitted." + }, + "domain": { + "type": "string", + "optional": true, + "description": "The domain of the cookie. If omitted, the cookie becomes a host-only cookie." + }, + "path": { + "type": "string", + "optional": true, + "description": "The path of the cookie. Defaults to the path portion of the url parameter." + }, + "secure": { + "type": "boolean", + "optional": true, + "description": "Whether the cookie should be marked as Secure. Defaults to false." + }, + "httpOnly": { + "type": "boolean", + "optional": true, + "description": "Whether the cookie should be marked as HttpOnly. Defaults to false." + }, + "sameSite": { + "$ref": "SameSiteStatus", + "optional": true, + "description": "The cookie's same-site status.", + "default": "no_restriction" + }, + "expirationDate": { + "type": "number", + "optional": true, + "description": "The expiration date of the cookie as the number of seconds since the UNIX epoch. If omitted, the cookie becomes a session cookie." + }, + "storeId": { + "type": "string", + "optional": true, + "description": "The ID of the cookie store in which to set the cookie. By default, the cookie is set in the current execution context's cookie store." + }, + "firstPartyDomain": { + "type": "string", + "optional": true, + "description": "The first-party domain of the cookie. This attribute is required if First-Party Isolation is enabled." + }, + "partitionKey": { + "$ref": "PartitionKey", + "optional": true, + "description": "The storage partition, if the cookie is part of partitioned storage. By default, non-partitioned storage is used." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "cookie", + "$ref": "Cookie", + "optional": true, + "description": "Contains details about the cookie that's been set. If setting failed for any reason, this will be \"null\", and $(ref:runtime.lastError) will be set." + } + ] + } + ] + }, + { + "name": "remove", + "type": "function", + "description": "Deletes a cookie by name.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Information to identify the cookie to remove.", + "properties": { + "url": { + "type": "string", + "description": "The URL associated with the cookie. If host permissions for this URL are not specified in the manifest file, the API call will fail." + }, + "name": { + "type": "string", + "description": "The name of the cookie to remove." + }, + "storeId": { + "type": "string", + "optional": true, + "description": "The ID of the cookie store to look in for the cookie. If unspecified, the cookie is looked for by default in the current execution context's cookie store." + }, + "firstPartyDomain": { + "type": "string", + "optional": true, + "description": "The first-party domain associated with the cookie. This attribute is required if First-Party Isolation is enabled." + }, + "partitionKey": { + "$ref": "PartitionKey", + "optional": true, + "description": "The storage partition, if the cookie is part of partitioned storage. By default, non-partitioned storage is used." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "details", + "type": "object", + "description": "Contains details about the cookie that's been removed. If removal failed for any reason, this will be \"null\", and $(ref:runtime.lastError) will be set.", + "optional": true, + "properties": { + "url": { + "type": "string", + "description": "The URL associated with the cookie that's been removed." + }, + "name": { + "type": "string", + "description": "The name of the cookie that's been removed." + }, + "storeId": { + "type": "string", + "description": "The ID of the cookie store from which the cookie was removed." + }, + "firstPartyDomain": { + "type": "string", + "description": "The first-party domain associated with the cookie that's been removed." + }, + "partitionKey": { + "$ref": "PartitionKey", + "optional": true, + "description": "The storage partition, if the cookie is part of partitioned storage. null if not partitioned." + } + } + } + ] + } + ] + }, + { + "name": "getAllCookieStores", + "type": "function", + "description": "Lists all existing cookie stores.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "cookieStores", + "type": "array", + "items": { "$ref": "CookieStore" }, + "description": "All the existing cookie stores." + } + ] + } + ] + } + ], + "events": [ + { + "name": "onChanged", + "type": "function", + "description": "Fired when a cookie is set or removed. As a special case, note that updating a cookie's properties is implemented as a two step process: the cookie to be updated is first removed entirely, generating a notification with \"cause\" of \"overwrite\" . Afterwards, a new cookie is written with the updated values, generating a second notification with \"cause\" \"explicit\".", + "parameters": [ + { + "type": "object", + "name": "changeInfo", + "properties": { + "removed": { + "type": "boolean", + "description": "True if a cookie was removed." + }, + "cookie": { + "$ref": "Cookie", + "description": "Information about the cookie that was set or removed." + }, + "cause": { + "$ref": "OnChangedCause", + "description": "The underlying reason behind the cookie's change." + } + } + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/declarative_net_request.json b/toolkit/components/extensions/schemas/declarative_net_request.json new file mode 100644 index 0000000000..e7bdc02041 --- /dev/null +++ b/toolkit/components/extensions/schemas/declarative_net_request.json @@ -0,0 +1,785 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "Permission", + "choices": [ + { + "type": "string", + "enum": ["declarativeNetRequest"] + } + ] + }, + { + "$extend": "OptionalPermission", + "choices": [ + { + "type": "string", + "enum": ["declarativeNetRequestFeedback"] + } + ] + }, + { + "$extend": "PermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["declarativeNetRequestWithHostAccess"] + } + ] + }, + { + "$extend": "WebExtensionManifest", + "properties": { + "declarative_net_request": { + "type": "object", + "optional": true, + "additionalProperties": { "$ref": "UnrecognizedProperty" }, + "properties": { + "rule_resources": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": { "$ref": "UnrecognizedProperty" }, + "properties": { + "id": { + "type": "string", + "pattern": "^[^_]", + "description": "A non-empty string uniquely identifying the ruleset. IDs beginning with '_' are reserved for internal use." + }, + "enabled": { + "type": "boolean", + "description": "Whether the ruleset is enabled by default." + }, + "path": { + "$ref": "manifest.ExtensionURL", + "description": "The path of the JSON ruleset relative to the extension directory." + } + } + } + } + } + } + } + } + ] + }, + { + "namespace": "declarativeNetRequest", + "description": "Use the declarativeNetRequest API to block or modify network requests by specifying declarative rules.", + "permissions": [ + "declarativeNetRequest", + "declarativeNetRequestWithHostAccess" + ], + "types": [ + { + "id": "ResourceType", + "type": "string", + "description": "How the requested resource will be used. Comparable to the webRequest.ResourceType type.", + "enum": [ + "main_frame", + "sub_frame", + "stylesheet", + "script", + "image", + "object", + "object_subrequest", + "xmlhttprequest", + "xslt", + "ping", + "beacon", + "xml_dtd", + "font", + "media", + "websocket", + "csp_report", + "imageset", + "web_manifest", + "speculative", + "other" + ] + }, + { + "id": "UnsupportedRegexReason", + "type": "string", + "description": "Describes the reason why a given regular expression isn't supported.", + "enum": ["syntaxError", "memoryLimitExceeded"] + }, + { + "id": "MatchedRule", + "type": "object", + "properties": { + "ruleId": { + "type": "integer", + "description": "A matching rule's ID." + }, + "rulesetId": { + "type": "string", + "description": "ID of the Ruleset this rule belongs to." + }, + "extensionId": { + "type": "string", + "description": "ID of the extension, if this rule belongs to a different extension.", + "optional": true + } + } + }, + { + "id": "URLTransform", + "type": "object", + "description": "Describes the type of the Rule.action.redirect.transform property.", + "properties": { + "scheme": { + "type": "string", + "optional": true, + "description": "The new scheme for the request.", + "enum": ["http", "https", "moz-extension"] + }, + "username": { + "type": "string", + "optional": true, + "description": "The new username for the request." + }, + "password": { + "type": "string", + "optional": true, + "description": "The new password for the request." + }, + "host": { + "type": "string", + "optional": true, + "description": "The new host name for the request." + }, + "port": { + "type": "string", + "optional": true, + "description": "The new port for the request. If empty, the existing port is cleared." + }, + "path": { + "type": "string", + "optional": true, + "description": "The new path for the request. If empty, the existing path is cleared." + }, + "query": { + "type": "string", + "optional": true, + "description": "The new query for the request. Should be either empty, in which case the existing query is cleared; or should begin with '?'. Cannot be specified if 'queryTransform' is specified." + }, + "queryTransform": { + "type": "object", + "optional": true, + "description": "Add, remove or replace query key-value pairs. Cannot be specified if 'query' is specified.", + "properties": { + "removeParams": { + "type": "array", + "optional": true, + "description": "The list of query keys to be removed.", + "items": { + "type": "string" + } + }, + "addOrReplaceParams": { + "type": "array", + "optional": true, + "description": "The list of query key-value pairs to be added or replaced.", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + }, + "replaceOnly": { + "type": "boolean", + "optional": true, + "description": "If true, the query key is replaced only if it's already present. Otherwise, the key is also added if it's missing.", + "default": false + } + } + } + } + } + }, + "fragment": { + "type": "string", + "optional": true, + "description": "The new fragment for the request. Should be either empty, in which case the existing fragment is cleared; or should begin with '#'." + } + } + }, + { + "id": "Rule", + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "An id which uniquely identifies a rule. Mandatory and should be >= 1.", + "minimum": 1 + }, + "priority": { + "type": "integer", + "optional": true, + "description": "Rule priority. Defaults to 1. When specified, should be >= 1", + "minimum": 1, + "default": 1 + }, + "condition": { + "type": "object", + "description": "The condition under which this rule is triggered.", + "properties": { + "urlFilter": { + "type": "string", + "optional": true, + "description": "TODO: link to doc explaining supported pattern. The pattern which is matched against the network request url. Only one of 'urlFilter' or 'regexFilter' can be specified." + }, + "regexFilter": { + "type": "string", + "optional": true, + "description": "Regular expression to match against the network request url. Only one of 'urlFilter' or 'regexFilter' can be specified." + }, + "isUrlFilterCaseSensitive": { + "type": "boolean", + "optional": true, + "description": "Whether 'urlFilter' or 'regexFilter' is case-sensitive." + }, + "initiatorDomains": { + "type": "array", + "optional": true, + "description": "The rule will only match network requests originating from the list of 'initiatorDomains'. If the list is omitted, the rule is applied to requests from all domains.", + "minItems": 1, + "items": { + "type": "string", + "format": "canonicalDomain" + } + }, + "excludedInitiatorDomains": { + "type": "array", + "optional": true, + "description": "The rule will not match network requests originating from the list of 'initiatorDomains'. If the list is empty or omitted, no domains are excluded. This takes precedence over 'initiatorDomains'.", + "items": { + "type": "string", + "format": "canonicalDomain" + } + }, + "requestDomains": { + "type": "array", + "optional": true, + "description": "The rule will only match network requests when the domain matches one from the list of 'requestDomains'. If the list is omitted, the rule is applied to requests from all domains.", + "minItems": 1, + "items": { + "type": "string", + "format": "canonicalDomain" + } + }, + "excludedRequestDomains": { + "type": "array", + "optional": true, + "description": "The rule will not match network requests when the domains matches one from the list of 'excludedRequestDomains'. If the list is empty or omitted, no domains are excluded. This takes precedence over 'requestDomains'.", + "items": { + "type": "string", + "format": "canonicalDomain" + } + }, + "resourceTypes": { + "type": "array", + "optional": true, + "description": "List of resource types which the rule can match. When the rule action is 'allowAllRequests', this must be specified and may only contain 'main_frame' or 'sub_frame'. Cannot be specified if 'excludedResourceTypes' is specified. If neither of them is specified, all resource types except 'main_frame' are matched.", + "minItems": 1, + "items": { + "$ref": "ResourceType" + } + }, + "excludedResourceTypes": { + "type": "array", + "optional": true, + "description": "List of resource types which the rule won't match. Cannot be specified if 'resourceTypes' is specified. If neither of them is specified, all resource types except 'main_frame' are matched.", + "items": { + "$ref": "ResourceType" + } + }, + "requestMethods": { + "type": "array", + "optional": true, + "description": "List of HTTP request methods which the rule can match. Should be a lower-case method such as 'connect', 'delete', 'get', 'head', 'options', 'patch', 'post', 'put'.'", + "minItems": 1, + "items": { + "type": "string" + } + }, + "excludedRequestMethods": { + "type": "array", + "optional": true, + "description": "List of request methods which the rule won't match. Cannot be specified if 'requestMethods' is specified. If neither of them is specified, all request methods are matched.", + "items": { + "type": "string" + } + }, + "domainType": { + "type": "string", + "optional": true, + "description": "Specifies whether the network request is first-party or third-party to the domain from which it originated. If omitted, all requests are matched.", + "enum": ["firstParty", "thirdParty"] + }, + "tabIds": { + "type": "array", + "optional": true, + "description": "List of tabIds which the rule should match. An ID of -1 matches requests which don't originate from a tab. Only supported for session-scoped rules.", + "minItems": 1, + "items": { + "type": "integer" + } + }, + "excludedTabIds": { + "type": "array", + "optional": true, + "description": "List of tabIds which the rule should not match. An ID of -1 excludes requests which don't originate from a tab. Only supported for session-scoped rules.", + "items": { + "type": "integer" + } + } + } + }, + "action": { + "type": "object", + "description": "The action to take if this rule is matched.", + "properties": { + "type": { + "type": "string", + "enum": [ + "block", + "redirect", + "allow", + "upgradeScheme", + "modifyHeaders", + "allowAllRequests" + ] + }, + "redirect": { + "type": "object", + "optional": true, + "description": "Describes how the redirect should be performed. Only valid when type is 'redirect'.", + "properties": { + "extensionPath": { + "type": "string", + "optional": true, + "description": "Path relative to the extension directory. Should start with '/'." + }, + "transform": { + "$ref": "URLTransform", + "optional": true, + "description": "Url transformations to perform." + }, + "url": { + "type": "string", + "format": "url", + "optional": true, + "description": "The redirect url. Redirects to JavaScript urls are not allowed." + }, + "regexSubstitution": { + "type": "string", + "optional": true, + "description": "Substitution pattern for rules which specify a 'regexFilter'. The first match of regexFilter within the url will be replaced with this pattern. Within regexSubstitution, backslash-escaped digits (\\1 to \\9) can be used to insert the corresponding capture groups. \\0 refers to the entire matching text." + } + } + }, + "requestHeaders": { + "type": "array", + "optional": true, + "description": "The request headers to modify for the request. Only valid when type is 'modifyHeaders'.", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "header": { + "type": "string", + "description": "The name of the request header to be modified." + }, + "operation": { + "type": "string", + "description": "The operation to be performed on a header.", + "enum": ["append", "set", "remove"] + }, + "value": { + "type": "string", + "optional": true, + "description": "The new value for the header. Must be specified for the 'append' and 'set' operations." + } + } + } + }, + "responseHeaders": { + "type": "array", + "optional": true, + "description": "The response headers to modify for the request. Only valid when type is 'modifyHeaders'.", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "header": { + "type": "string", + "description": "The name of the response header to be modified." + }, + "operation": { + "type": "string", + "description": "The operation to be performed on a header.", + "enum": ["append", "set", "remove"] + }, + "value": { + "type": "string", + "optional": true, + "description": "The new value for the header. Must be specified for the 'append' and 'set' operations." + } + } + } + } + } + } + } + } + ], + "functions": [ + { + "name": "updateDynamicRules", + "type": "function", + "description": "Modifies the current set of dynamic rules for the extension. The rules with IDs listed in options.removeRuleIds are first removed, and then the rules given in options.addRules are added. These rules are persisted across browser sessions and extension updates.", + "async": "callback", + "parameters": [ + { + "name": "options", + "type": "object", + "properties": { + "removeRuleIds": { + "type": "array", + "optional": true, + "description": "IDs of the rules to remove. Any invalid IDs will be ignored.", + "items": { + "type": "integer" + } + }, + "addRules": { + "type": "array", + "optional": true, + "description": "Rules to add.", + "items": { + "$ref": "Rule" + } + } + } + }, + { + "name": "callback", + "type": "function", + "description": "Called when the dynamic rules have been updated", + "parameters": [] + } + ] + }, + { + "name": "updateSessionRules", + "type": "function", + "description": "Modifies the current set of session scoped rules for the extension. The rules with IDs listed in options.removeRuleIds are first removed, and then the rules given in options.addRules are added. These rules are not persisted across sessions and are backed in memory.", + "async": "callback", + "parameters": [ + { + "name": "options", + "type": "object", + "properties": { + "removeRuleIds": { + "type": "array", + "optional": true, + "description": "IDs of the rules to remove. Any invalid IDs will be ignored.", + "items": { + "type": "integer" + } + }, + "addRules": { + "type": "array", + "optional": true, + "description": "Rules to add.", + "items": { + "$ref": "Rule" + } + } + } + }, + { + "name": "callback", + "type": "function", + "description": "Called when the session rules have been updated", + "parameters": [] + } + ] + }, + { + "name": "getEnabledRulesets", + "type": "function", + "description": "Returns the ids for the current set of enabled static rulesets.", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "parameters": [ + { + "type": "array", + "name": "rulesetIds", + "items": { "type": "string" } + } + ] + } + ] + }, + { + "name": "updateEnabledRulesets", + "type": "function", + "description": "Returns the ids for the current set of enabled static rulesets.", + "async": "callback", + "parameters": [ + { + "name": "updateRulesetOptions", + "type": "object", + "properties": { + "disableRulesetIds": { + "type": "array", + "items": { "type": "string" }, + "optional": true, + "default": [] + }, + "enableRulesetIds": { + "type": "array", + "items": { "type": "string" }, + "optional": true, + "default": [] + } + } + }, + { + "name": "callback", + "type": "function", + "parameters": [] + } + ] + }, + { + "name": "getAvailableStaticRuleCount", + "type": "function", + "description": "Returns the remaining number of static rules an extension can enable", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "count", + "type": "integer" + } + ] + } + ] + }, + { + "name": "getDynamicRules", + "type": "function", + "description": "Returns the current set of dynamic rules for the extension.", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "dynamicRules", + "type": "array", + "items": { + "$ref": "Rule" + } + } + ] + } + ] + }, + { + "name": "getSessionRules", + "type": "function", + "description": "Returns the current set of session scoped rules for the extension.", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "sessionRules", + "type": "array", + "items": { + "$ref": "Rule" + } + } + ] + } + ] + }, + { + "name": "isRegexSupported", + "type": "function", + "description": "Checks if the given regular expression will be supported as a 'regexFilter' rule condition.", + "async": "callback", + "parameters": [ + { + "name": "regexOptions", + "type": "object", + "properties": { + "regex": { + "type": "string", + "description": "The regular expresson to check." + }, + "isCaseSensitive": { + "type": "boolean", + "optional": true, + "description": "Whether the 'regex' specified is case sensitive.", + "default": false + }, + "requireCapturing": { + "type": "boolean", + "optional": true, + "description": "Whether the 'regex' specified requires capturing. Capturing is only required for redirect rules which specify a 'regexSubstition' action.", + "default": false + } + } + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "result", + "type": "object", + "properties": { + "isSupported": { + "type": "boolean", + "description": "Whether the given regex is supported" + }, + "reason": { + "$ref": "UnsupportedRegexReason", + "optional": true, + "description": "Specifies the reason why the regular expression is not supported. Only provided if 'isSupported' is false." + } + } + } + ] + } + ] + }, + { + "name": "testMatchOutcome", + "type": "function", + "description": "Checks if any of the extension's declarativeNetRequest rules would match a hypothetical request.", + "permissions": ["declarativeNetRequestFeedback"], + "async": "callback", + "parameters": [ + { + "name": "request", + "type": "object", + "description": "The details of the request to test.", + "properties": { + "url": { + "type": "string", + "description": "The URL of the hypothetical request." + }, + "initiator": { + "type": "string", + "description": "The initiator URL (if any) for the hypothetical request.", + "optional": true + }, + "method": { + "type": "string", + "description": "Standard HTTP method of the hypothetical request.", + "optional": true, + "default": "get" + }, + "type": { + "$ref": "ResourceType", + "description": "The resource type of the hypothetical request." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the hypothetical request takes place. Does not need to correspond to a real tab ID. Default is -1, meaning that the request isn't related to a tab.", + "optional": true, + "default": -1 + } + } + }, + { + "name": "options", + "type": "object", + "optional": true, + "properties": { + "includeOtherExtensions": { + "type": "boolean", + "description": "Whether to account for rules from other installed extensions during rule evaluation.", + "optional": true + } + } + }, + { + "name": "callback", + "type": "function", + "description": "Called with the details of matched rules.", + "parameters": [ + { + "name": "result", + "type": "object", + "properties": { + "matchedRules": { + "type": "array", + "description": "The rules (if any) that match the hypothetical request.", + "items": { + "$ref": "MatchedRule" + } + } + } + } + ] + } + ] + } + ], + "properties": { + "DYNAMIC_RULESET_ID": { + "type": "string", + "value": "_dynamic", + "description": "Ruleset ID for the dynamic rules added by the extension." + }, + "GUARANTEED_MINIMUM_STATIC_RULES": { + "type": "number", + "description": "The minimum number of static rules guaranteed to an extension across its enabled static rulesets. Any rules above this limit will count towards the global static rule limit." + }, + "MAX_NUMBER_OF_STATIC_RULESETS": { + "type": "number", + "description": "The maximum number of static Rulesets an extension can specify as part of the rule_resources manifest key." + }, + "MAX_NUMBER_OF_ENABLED_STATIC_RULESETS": { + "type": "number", + "description": "The maximum number of static Rulesets an extension can enable at any one time." + }, + "MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES": { + "type": "number", + "description": "The maximum number of dynamic and session rules an extension can add. NOTE: in the Firefox we are enforcing this limit to the session and dynamic rules count separately, instead of enforcing it to the rules count for both combined as the Chrome implementation does." + }, + "MAX_NUMBER_OF_REGEX_RULES": { + "type": "number", + "description": "The maximum number of regular expression rules that an extension can add. This limit is evaluated separately for the set of session rules, dynamic rules and those specified in the rule_resources file." + }, + "SESSION_RULESET_ID": { + "type": "string", + "value": "_session", + "description": "Ruleset ID for the session-scoped rules added by the extension." + } + } + } +] diff --git a/toolkit/components/extensions/schemas/dns.json b/toolkit/components/extensions/schemas/dns.json new file mode 100644 index 0000000000..415849c6de --- /dev/null +++ b/toolkit/components/extensions/schemas/dns.json @@ -0,0 +1,82 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "PermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["dns"] + } + ] + } + ] + }, + { + "namespace": "dns", + "description": "Asynchronous DNS API", + "permissions": ["dns"], + "types": [ + { + "id": "DNSRecord", + "type": "object", + "description": "An object encapsulating a DNS Record.", + "properties": { + "canonicalName": { + "type": "string", + "optional": true, + "description": "The canonical hostname for this record. this value is empty if the record was not fetched with the 'canonical_name' flag." + }, + "isTRR": { + "type": "string", + "description": "Record retreived with TRR." + }, + "addresses": { + "type": "array", + "items": { "type": "string" } + } + } + }, + { + "id": "ResolveFlags", + "type": "array", + "items": { + "type": "string", + "enum": [ + "allow_name_collisions", + "bypass_cache", + "canonical_name", + "disable_ipv4", + "disable_ipv6", + "disable_trr", + "offline", + "priority_low", + "priority_medium", + "speculate" + ] + } + } + ], + "functions": [ + { + "name": "resolve", + "type": "function", + "description": "Resolves a hostname to a DNS record.", + "async": true, + "parameters": [ + { + "name": "hostname", + "type": "string" + }, + { + "name": "flags", + "optional": true, + "default": [], + "$ref": "ResolveFlags" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/downloads.json b/toolkit/components/extensions/schemas/downloads.json new file mode 100644 index 0000000000..ed3c1002e0 --- /dev/null +++ b/toolkit/components/extensions/schemas/downloads.json @@ -0,0 +1,810 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermission", + "choices": [ + { + "type": "string", + "enum": ["downloads", "downloads.open"] + } + ] + } + ] + }, + { + "namespace": "downloads", + "permissions": ["downloads"], + "types": [ + { + "id": "FilenameConflictAction", + "type": "string", + "enum": ["uniquify", "overwrite", "prompt"] + }, + { + "id": "InterruptReason", + "type": "string", + "enum": [ + "FILE_FAILED", + "FILE_ACCESS_DENIED", + "FILE_NO_SPACE", + "FILE_NAME_TOO_LONG", + "FILE_TOO_LARGE", + "FILE_VIRUS_INFECTED", + "FILE_TRANSIENT_ERROR", + "FILE_BLOCKED", + "FILE_SECURITY_CHECK_FAILED", + "FILE_TOO_SHORT", + "NETWORK_FAILED", + "NETWORK_TIMEOUT", + "NETWORK_DISCONNECTED", + "NETWORK_SERVER_DOWN", + "NETWORK_INVALID_REQUEST", + "SERVER_FAILED", + "SERVER_NO_RANGE", + "SERVER_BAD_CONTENT", + "SERVER_UNAUTHORIZED", + "SERVER_CERT_PROBLEM", + "SERVER_FORBIDDEN", + "USER_CANCELED", + "USER_SHUTDOWN", + "CRASH" + ] + }, + { + "id": "DangerType", + "type": "string", + "enum": [ + "file", + "url", + "content", + "uncommon", + "host", + "unwanted", + "safe", + "accepted" + ], + "description": "
file
The download's filename is suspicious.
url
The download's URL is known to be malicious.
content
The downloaded file is known to be malicious.
uncommon
The download's URL is not commonly downloaded and could be dangerous.
safe
The download presents no known danger to the user's computer.
These string constants will never change, however the set of DangerTypes may change." + }, + { + "id": "State", + "type": "string", + "enum": ["in_progress", "interrupted", "complete"], + "description": "
in_progress
The download is currently receiving data from the server.
interrupted
An error broke the connection with the file host.
complete
The download completed successfully.
These string constants will never change, however the set of States may change." + }, + { + "id": "DownloadItem", + "type": "object", + "properties": { + "id": { + "description": "An identifier that is persistent across browser sessions.", + "type": "integer" + }, + "url": { + "description": "Absolute URL.", + "type": "string" + }, + "referrer": { + "type": "string", + "optional": true + }, + "filename": { + "description": "Absolute local path.", + "type": "string" + }, + "incognito": { + "description": "False if this download is recorded in the history, true if it is not recorded.", + "type": "boolean" + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "danger": { + "$ref": "DangerType", + "description": "Indication of whether this download is thought to be safe or known to be suspicious." + }, + "mime": { + "description": "The file's MIME type.", + "type": "string", + "optional": true + }, + "startTime": { + "description": "Number of milliseconds between the unix epoch and when this download began.", + "type": "string" + }, + "endTime": { + "description": "Number of milliseconds between the unix epoch and when this download ended.", + "optional": true, + "type": "string" + }, + "estimatedEndTime": { + "type": "string", + "optional": true + }, + "state": { + "$ref": "State", + "description": "Indicates whether the download is progressing, interrupted, or complete." + }, + "paused": { + "description": "True if the download has stopped reading data from the host, but kept the connection open.", + "type": "boolean" + }, + "canResume": { + "type": "boolean" + }, + "error": { + "description": "Number indicating why a download was interrupted.", + "optional": true, + "$ref": "InterruptReason" + }, + "bytesReceived": { + "description": "Number of bytes received so far from the host, without considering file compression.", + "type": "number" + }, + "totalBytes": { + "description": "Number of bytes in the whole file, without considering file compression, or -1 if unknown.", + "type": "number" + }, + "fileSize": { + "description": "Number of bytes in the whole file post-decompression, or -1 if unknown.", + "type": "number" + }, + "exists": { + "type": "boolean" + }, + "byExtensionId": { + "type": "string", + "optional": true + }, + "byExtensionName": { + "type": "string", + "optional": true + } + } + }, + { + "id": "StringDelta", + "type": "object", + "properties": { + "current": { + "optional": true, + "type": "string" + }, + "previous": { + "optional": true, + "type": "string" + } + } + }, + { + "id": "DoubleDelta", + "type": "object", + "properties": { + "current": { + "optional": true, + "type": "number" + }, + "previous": { + "optional": true, + "type": "number" + } + } + }, + { + "id": "BooleanDelta", + "type": "object", + "properties": { + "current": { + "optional": true, + "type": "boolean" + }, + "previous": { + "optional": true, + "type": "boolean" + } + } + }, + { + "id": "DownloadTime", + "description": "A time specified as a Date object, a number or string representing milliseconds since the epoch, or an ISO 8601 string", + "choices": [ + { + "type": "string", + "pattern": "^[1-9]\\d*$" + }, + { + "$ref": "extensionTypes.Date" + } + ] + }, + { + "id": "DownloadQuery", + "description": "Parameters that combine to specify a predicate that can be used to select a set of downloads. Used for example in search() and erase()", + "type": "object", + "properties": { + "query": { + "description": "This array of search terms limits results to DownloadItems whose filename or url contain all of the search terms that do not begin with a dash '-' and none of the search terms that do begin with a dash.", + "optional": true, + "type": "array", + "items": { "type": "string" } + }, + "startedBefore": { + "description": "Limits results to downloads that started before the given ms since the epoch.", + "optional": true, + "$ref": "DownloadTime" + }, + "startedAfter": { + "description": "Limits results to downloads that started after the given ms since the epoch.", + "optional": true, + "$ref": "DownloadTime" + }, + "endedBefore": { + "description": "Limits results to downloads that ended before the given ms since the epoch.", + "optional": true, + "$ref": "DownloadTime" + }, + "endedAfter": { + "description": "Limits results to downloads that ended after the given ms since the epoch.", + "optional": true, + "$ref": "DownloadTime" + }, + "totalBytesGreater": { + "description": "Limits results to downloads whose totalBytes is greater than the given integer.", + "optional": true, + "type": "number" + }, + "totalBytesLess": { + "description": "Limits results to downloads whose totalBytes is less than the given integer.", + "optional": true, + "type": "number" + }, + "filenameRegex": { + "description": "Limits results to DownloadItems whose filename matches the given regular expression.", + "optional": true, + "type": "string" + }, + "urlRegex": { + "description": "Limits results to DownloadItems whose url matches the given regular expression.", + "optional": true, + "type": "string" + }, + "limit": { + "description": "Setting this integer limits the number of results. Otherwise, all matching DownloadItems will be returned.", + "optional": true, + "type": "integer" + }, + "orderBy": { + "description": "Setting elements of this array to DownloadItem properties in order to sort the search results. For example, setting orderBy='startTime' sorts the DownloadItems by their start time in ascending order. To specify descending order, prefix orderBy with a hyphen: '-startTime'.", + "optional": true, + "type": "array", + "items": { "type": "string" } + }, + "id": { + "type": "integer", + "optional": true + }, + "url": { + "description": "Absolute URL.", + "optional": true, + "type": "string" + }, + "filename": { + "description": "Absolute local path.", + "optional": true, + "type": "string" + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "danger": { + "$ref": "DangerType", + "description": "Indication of whether this download is thought to be safe or known to be suspicious.", + "optional": true + }, + "mime": { + "description": "The file's MIME type.", + "optional": true, + "type": "string" + }, + "startTime": { + "optional": true, + "type": "string" + }, + "endTime": { + "optional": true, + "type": "string" + }, + "state": { + "$ref": "State", + "description": "Indicates whether the download is progressing, interrupted, or complete.", + "optional": true + }, + "paused": { + "description": "True if the download has stopped reading data from the host, but kept the connection open.", + "optional": true, + "type": "boolean" + }, + "error": { + "description": "Why a download was interrupted.", + "optional": true, + "$ref": "InterruptReason" + }, + "bytesReceived": { + "description": "Number of bytes received so far from the host, without considering file compression.", + "optional": true, + "type": "number" + }, + "totalBytes": { + "description": "Number of bytes in the whole file, without considering file compression, or -1 if unknown.", + "optional": true, + "type": "number" + }, + "fileSize": { + "description": "Number of bytes in the whole file post-decompression, or -1 if unknown.", + "optional": true, + "type": "number" + }, + "exists": { + "type": "boolean", + "optional": true + } + } + } + ], + "functions": [ + { + "name": "download", + "type": "function", + "async": "callback", + "description": "Download a URL. If the URL uses the HTTP[S] protocol, then the request will include all cookies currently set for its hostname. If both filename and saveAs are specified, then the Save As dialog will be displayed, pre-populated with the specified filename. If the download started successfully, callback will be called with the new DownloadItem's downloadId. If there was an error starting the download, then callback will be called with downloadId=undefined and chrome.extension.lastError will contain a descriptive string. The error strings are not guaranteed to remain backwards compatible between releases. You must not parse it.", + "parameters": [ + { + "description": "What to download and how.", + "name": "options", + "type": "object", + "properties": { + "url": { + "description": "The URL to download.", + "type": "string", + "format": "url" + }, + "filename": { + "description": "A file path relative to the Downloads directory to contain the downloaded file.", + "optional": true, + "type": "string" + }, + "incognito": { + "description": "Whether to associate the download with a private browsing session.", + "optional": true, + "default": false, + "type": "boolean" + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity; requires \"cookies\" permission." + }, + "conflictAction": { + "$ref": "FilenameConflictAction", + "optional": true + }, + "saveAs": { + "description": "Use a file-chooser to allow the user to select a filename. If the option is not specified, the file chooser will be shown only if the Firefox \"Always ask you where to save files\" option is enabled (i.e. the pref browser.download.useDownloadDir is set to false).", + "optional": true, + "type": "boolean" + }, + "method": { + "description": "The HTTP method to use if the URL uses the HTTP[S] protocol.", + "enum": ["GET", "POST"], + "optional": true, + "type": "string" + }, + "headers": { + "optional": true, + "type": "array", + "description": "Extra HTTP headers to send with the request if the URL uses the HTTP[s] protocol. Each header is represented as a dictionary containing the keys name and either value or binaryValue, restricted to those allowed by XMLHttpRequest.", + "items": { + "type": "object", + "properties": { + "name": { + "description": "Name of the HTTP header.", + "type": "string" + }, + "value": { + "description": "Value of the HTTP header.", + "type": "string" + } + } + } + }, + "body": { + "description": "Post body.", + "optional": true, + "type": "string" + }, + "allowHttpErrors": { + "description": "When this flag is set to true, then the browser will allow downloads to proceed after encountering HTTP errors such as 404 Not Found.", + "optional": true, + "default": false, + "type": "boolean" + } + } + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [ + { + "name": "downloadId", + "type": "integer" + } + ] + } + ] + }, + { + "name": "search", + "type": "function", + "async": "callback", + "description": "Find DownloadItems. Set query to the empty object to get all DownloadItems. To get a specific DownloadItem, set only the id field.", + "parameters": [ + { + "name": "query", + "$ref": "DownloadQuery" + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "items": { + "$ref": "DownloadItem" + }, + "name": "results", + "type": "array" + } + ] + } + ] + }, + { + "name": "pause", + "type": "function", + "async": "callback", + "description": "Pause the download. If the request was successful the download is in a paused state. Otherwise chrome.extension.lastError contains an error message. The request will fail if the download is not active.", + "parameters": [ + { + "description": "The id of the download to pause.", + "name": "downloadId", + "type": "integer" + }, + { + "name": "callback", + "optional": true, + "parameters": [], + "type": "function" + } + ] + }, + { + "name": "resume", + "type": "function", + "async": "callback", + "description": "Resume a paused download. If the request was successful the download is in progress and unpaused. Otherwise chrome.extension.lastError contains an error message. The request will fail if the download is not active.", + "parameters": [ + { + "description": "The id of the download to resume.", + "name": "downloadId", + "type": "integer" + }, + { + "name": "callback", + "optional": true, + "parameters": [], + "type": "function" + } + ] + }, + { + "name": "cancel", + "type": "function", + "async": "callback", + "description": "Cancel a download. When callback is run, the download is cancelled, completed, interrupted or doesn't exist anymore.", + "parameters": [ + { + "description": "The id of the download to cancel.", + "name": "downloadId", + "type": "integer" + }, + { + "name": "callback", + "optional": true, + "parameters": [], + "type": "function" + } + ] + }, + { + "name": "getFileIcon", + "type": "function", + "async": "callback", + "description": "Retrieve an icon for the specified download. For new downloads, file icons are available after the onCreated event has been received. The image returned by this function while a download is in progress may be different from the image returned after the download is complete. Icon retrieval is done by querying the underlying operating system or toolkit depending on the platform. The icon that is returned will therefore depend on a number of factors including state of the download, platform, registered file types and visual theme. If a file icon cannot be determined, chrome.extension.lastError will contain an error message.", + "parameters": [ + { + "description": "The identifier for the download.", + "name": "downloadId", + "type": "integer" + }, + { + "name": "options", + "optional": true, + "properties": { + "size": { + "description": "The size of the icon. The returned icon will be square with dimensions size * size pixels. The default size for the icon is 32x32 pixels.", + "optional": true, + "minimum": 1, + "maximum": 127, + "type": "integer" + } + }, + "type": "object" + }, + { + "name": "callback", + "parameters": [ + { + "name": "iconURL", + "optional": true, + "type": "string" + } + ], + "type": "function" + } + ] + }, + { + "name": "open", + "type": "function", + "async": "callback", + "requireUserInput": true, + "description": "Open the downloaded file.", + "permissions": ["downloads.open"], + "parameters": [ + { + "name": "downloadId", + "type": "integer" + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "show", + "type": "function", + "description": "Show the downloaded file in its folder in a file manager.", + "async": "callback", + "parameters": [ + { + "name": "downloadId", + "type": "integer" + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [ + { + "name": "success", + "type": "boolean" + } + ] + } + ] + }, + { + "name": "showDefaultFolder", + "type": "function", + "parameters": [] + }, + { + "name": "erase", + "type": "function", + "async": "callback", + "description": "Erase matching DownloadItems from history", + "parameters": [ + { + "name": "query", + "$ref": "DownloadQuery" + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [ + { + "items": { + "type": "integer" + }, + "name": "erasedIds", + "type": "array" + } + ] + } + ] + }, + { + "name": "removeFile", + "async": "callback", + "type": "function", + "parameters": [ + { + "name": "downloadId", + "type": "integer" + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [] + } + ] + }, + { + "description": "Prompt the user to either accept or cancel a dangerous download. acceptDanger() does not automatically accept dangerous downloads.", + "name": "acceptDanger", + "unsupported": true, + "parameters": [ + { + "name": "downloadId", + "type": "integer" + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [] + } + ], + "type": "function" + }, + { + "description": "Initiate dragging the file to another application.", + "name": "drag", + "unsupported": true, + "parameters": [ + { + "name": "downloadId", + "type": "integer" + } + ], + "type": "function" + }, + { + "name": "setShelfEnabled", + "type": "function", + "unsupported": true, + "parameters": [ + { + "name": "enabled", + "type": "boolean" + } + ] + } + ], + "events": [ + { + "description": "This event fires with the DownloadItem object when a download begins.", + "name": "onCreated", + "parameters": [ + { + "$ref": "DownloadItem", + "name": "downloadItem" + } + ], + "type": "function" + }, + { + "description": "Fires with the downloadId when a download is erased from history.", + "name": "onErased", + "parameters": [ + { + "name": "downloadId", + "description": "The id of the DownloadItem that was erased.", + "type": "integer" + } + ], + "type": "function" + }, + { + "name": "onChanged", + "description": "When any of a DownloadItem's properties except bytesReceived changes, this event fires with the downloadId and an object containing the properties that changed.", + "parameters": [ + { + "name": "downloadDelta", + "type": "object", + "properties": { + "id": { + "description": "The id of the DownloadItem that changed.", + "type": "integer" + }, + "url": { + "description": "Describes a change in a DownloadItem's url.", + "optional": true, + "$ref": "StringDelta" + }, + "filename": { + "description": "Describes a change in a DownloadItem's filename.", + "optional": true, + "$ref": "StringDelta" + }, + "danger": { + "description": "Describes a change in a DownloadItem's danger.", + "optional": true, + "$ref": "StringDelta" + }, + "mime": { + "description": "Describes a change in a DownloadItem's mime.", + "optional": true, + "$ref": "StringDelta" + }, + "startTime": { + "description": "Describes a change in a DownloadItem's startTime.", + "optional": true, + "$ref": "StringDelta" + }, + "endTime": { + "description": "Describes a change in a DownloadItem's endTime.", + "optional": true, + "$ref": "StringDelta" + }, + "state": { + "description": "Describes a change in a DownloadItem's state.", + "optional": true, + "$ref": "StringDelta" + }, + "canResume": { + "optional": true, + "$ref": "BooleanDelta" + }, + "paused": { + "description": "Describes a change in a DownloadItem's paused.", + "optional": true, + "$ref": "BooleanDelta" + }, + "error": { + "description": "Describes a change in a DownloadItem's error.", + "optional": true, + "$ref": "StringDelta" + }, + "totalBytes": { + "description": "Describes a change in a DownloadItem's totalBytes.", + "optional": true, + "$ref": "DoubleDelta" + }, + "fileSize": { + "description": "Describes a change in a DownloadItem's fileSize.", + "optional": true, + "$ref": "DoubleDelta" + }, + "exists": { + "optional": true, + "$ref": "BooleanDelta" + } + } + } + ], + "type": "function" + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/events.json b/toolkit/components/extensions/schemas/events.json new file mode 100644 index 0000000000..b8dad2c6a5 --- /dev/null +++ b/toolkit/components/extensions/schemas/events.json @@ -0,0 +1,324 @@ +[ + { + "namespace": "events", + "description": "The chrome.events namespace contains common types used by APIs dispatching events to notify you when something interesting happens.", + "types": [ + { + "id": "Rule", + "type": "object", + "description": "Description of a declarative rule for handling events.", + "properties": { + "id": { + "type": "string", + "optional": true, + "description": "Optional identifier that allows referencing this rule." + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "optional": true, + "description": "Tags can be used to annotate rules and perform operations on sets of rules." + }, + "conditions": { + "type": "array", + "items": { "type": "any" }, + "description": "List of conditions that can trigger the actions." + }, + "actions": { + "type": "array", + "items": { "type": "any" }, + "description": "List of actions that are triggered if one of the condtions is fulfilled." + }, + "priority": { + "type": "integer", + "optional": true, + "description": "Optional priority of this rule. Defaults to 100." + } + } + }, + { + "id": "Event", + "type": "object", + "description": "An object which allows the addition and removal of listeners for a Chrome event.", + "functions": [ + { + "name": "addListener", + "type": "function", + "description": "Registers an event listener callback to an event.", + "parameters": [ + { + "name": "callback", + "type": "function", + "description": "Called when an event occurs. The parameters of this function depend on the type of event." + } + ] + }, + { + "name": "removeListener", + "type": "function", + "description": "Deregisters an event listener callback from an event.", + "parameters": [ + { + "name": "callback", + "type": "function", + "description": "Listener that shall be unregistered." + } + ] + }, + { + "name": "hasListener", + "type": "function", + "parameters": [ + { + "name": "callback", + "type": "function", + "description": "Listener whose registration status shall be tested." + } + ], + "returns": { + "type": "boolean", + "description": "True if callback is registered to the event." + } + }, + { + "name": "hasListeners", + "type": "function", + "parameters": [], + "returns": { + "type": "boolean", + "description": "True if any event listeners are registered to the event." + } + }, + { + "name": "addRules", + "unsupported": true, + "type": "function", + "description": "Registers rules to handle events.", + "parameters": [ + { + "name": "eventName", + "type": "string", + "description": "Name of the event this function affects." + }, + { + "name": "webViewInstanceId", + "type": "integer", + "description": "If provided, this is an integer that uniquely identfies the associated with this function call." + }, + { + "name": "rules", + "type": "array", + "items": { "$ref": "Rule" }, + "description": "Rules to be registered. These do not replace previously registered rules." + }, + { + "name": "callback", + "optional": true, + "type": "function", + "parameters": [ + { + "name": "rules", + "type": "array", + "items": { "$ref": "Rule" }, + "description": "Rules that were registered, the optional parameters are filled with values." + } + ], + "description": "Called with registered rules." + } + ] + }, + { + "name": "getRules", + "unsupported": true, + "type": "function", + "description": "Returns currently registered rules.", + "parameters": [ + { + "name": "eventName", + "type": "string", + "description": "Name of the event this function affects." + }, + { + "name": "webViewInstanceId", + "type": "integer", + "description": "If provided, this is an integer that uniquely identfies the associated with this function call." + }, + { + "name": "ruleIdentifiers", + "optional": true, + "type": "array", + "items": { "type": "string" }, + "description": "If an array is passed, only rules with identifiers contained in this array are returned." + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "rules", + "type": "array", + "items": { "$ref": "Rule" }, + "description": "Rules that were registered, the optional parameters are filled with values." + } + ], + "description": "Called with registered rules." + } + ] + }, + { + "name": "removeRules", + "unsupported": true, + "type": "function", + "description": "Unregisters currently registered rules.", + "parameters": [ + { + "name": "eventName", + "type": "string", + "description": "Name of the event this function affects." + }, + { + "name": "webViewInstanceId", + "type": "integer", + "description": "If provided, this is an integer that uniquely identfies the associated with this function call." + }, + { + "name": "ruleIdentifiers", + "optional": true, + "type": "array", + "items": { "type": "string" }, + "description": "If an array is passed, only rules with identifiers contained in this array are unregistered." + }, + { + "name": "callback", + "optional": true, + "type": "function", + "parameters": [], + "description": "Called when rules were unregistered." + } + ] + } + ] + }, + { + "id": "UrlFilter", + "type": "object", + "description": "Filters URLs for various criteria. See event filtering. All criteria are case sensitive.", + "properties": { + "hostContains": { + "type": "string", + "description": "Matches if the host name of the URL contains a specified string. To test whether a host name component has a prefix 'foo', use hostContains: '.foo'. This matches 'www.foobar.com' and 'foo.com', because an implicit dot is added at the beginning of the host name. Similarly, hostContains can be used to match against component suffix ('foo.') and to exactly match against components ('.foo.'). Suffix- and exact-matching for the last components need to be done separately using hostSuffix, because no implicit dot is added at the end of the host name.", + "optional": true + }, + "hostEquals": { + "type": "string", + "description": "Matches if the host name of the URL is equal to a specified string.", + "optional": true + }, + "hostPrefix": { + "type": "string", + "description": "Matches if the host name of the URL starts with a specified string.", + "optional": true + }, + "hostSuffix": { + "type": "string", + "description": "Matches if the host name of the URL ends with a specified string.", + "optional": true + }, + "pathContains": { + "type": "string", + "description": "Matches if the path segment of the URL contains a specified string.", + "optional": true + }, + "pathEquals": { + "type": "string", + "description": "Matches if the path segment of the URL is equal to a specified string.", + "optional": true + }, + "pathPrefix": { + "type": "string", + "description": "Matches if the path segment of the URL starts with a specified string.", + "optional": true + }, + "pathSuffix": { + "type": "string", + "description": "Matches if the path segment of the URL ends with a specified string.", + "optional": true + }, + "queryContains": { + "type": "string", + "description": "Matches if the query segment of the URL contains a specified string.", + "optional": true + }, + "queryEquals": { + "type": "string", + "description": "Matches if the query segment of the URL is equal to a specified string.", + "optional": true + }, + "queryPrefix": { + "type": "string", + "description": "Matches if the query segment of the URL starts with a specified string.", + "optional": true + }, + "querySuffix": { + "type": "string", + "description": "Matches if the query segment of the URL ends with a specified string.", + "optional": true + }, + "urlContains": { + "type": "string", + "description": "Matches if the URL (without fragment identifier) contains a specified string. Port numbers are stripped from the URL if they match the default port number.", + "optional": true + }, + "urlEquals": { + "type": "string", + "description": "Matches if the URL (without fragment identifier) is equal to a specified string. Port numbers are stripped from the URL if they match the default port number.", + "optional": true + }, + "urlMatches": { + "type": "string", + "description": "Matches if the URL (without fragment identifier) matches a specified regular expression. Port numbers are stripped from the URL if they match the default port number. The regular expressions use the RE2 syntax.", + "optional": true + }, + "originAndPathMatches": { + "type": "string", + "description": "Matches if the URL without query segment and fragment identifier matches a specified regular expression. Port numbers are stripped from the URL if they match the default port number. The regular expressions use the RE2 syntax.", + "optional": true + }, + "urlPrefix": { + "type": "string", + "description": "Matches if the URL (without fragment identifier) starts with a specified string. Port numbers are stripped from the URL if they match the default port number.", + "optional": true + }, + "urlSuffix": { + "type": "string", + "description": "Matches if the URL (without fragment identifier) ends with a specified string. Port numbers are stripped from the URL if they match the default port number.", + "optional": true + }, + "schemes": { + "type": "array", + "description": "Matches if the scheme of the URL is equal to any of the schemes specified in the array.", + "optional": true, + "items": { "type": "string" } + }, + "ports": { + "type": "array", + "description": "Matches if the port of the URL is contained in any of the specified port lists. For example [80, 443, [1000, 1200]] matches all requests on port 80, 443 and in the range 1000-1200.", + "optional": true, + "items": { + "choices": [ + { "type": "integer", "description": "A specific port." }, + { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { "type": "integer" }, + "description": "A pair of integers identiying the start and end (both inclusive) of a port range." + } + ] + } + } + } + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/experiments.json b/toolkit/components/extensions/schemas/experiments.json new file mode 100644 index 0000000000..78f23cd8f2 --- /dev/null +++ b/toolkit/components/extensions/schemas/experiments.json @@ -0,0 +1,119 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "Permission", + "choices": [ + { + "type": "string", + "pattern": "^experiments(\\.\\w+)+$" + } + ] + }, + { + "$extend": "WebExtensionManifest", + "properties": { + "experiment_apis": { + "type": "object", + "additionalProperties": { "$ref": "experiments.ExperimentAPI" }, + "optional": true, + "privileged": true + } + } + } + ] + }, + { + "namespace": "experiments", + "types": [ + { + "id": "ExperimentAPI", + "type": "object", + "properties": { + "schema": { "$ref": "ExperimentURL" }, + + "parent": { + "type": "object", + "properties": { + "events": { + "$ref": "APIEvents", + "optional": true, + "default": [] + }, + + "paths": { + "$ref": "APIPaths", + "optional": true, + "default": [] + }, + + "script": { "$ref": "ExperimentURL" }, + + "scopes": { + "type": "array", + "items": { "$ref": "APIParentScope", "onError": "warn" }, + "optional": true, + "default": [] + } + }, + "optional": true + }, + + "child": { + "type": "object", + "properties": { + "paths": { "$ref": "APIPaths" }, + + "script": { "$ref": "ExperimentURL" }, + + "scopes": { + "type": "array", + "minItems": 1, + "items": { "$ref": "APIChildScope", "onError": "warn" } + } + }, + "optional": true + } + } + }, + { + "id": "ExperimentURL", + "type": "string", + "format": "unresolvedRelativeUrl" + }, + { + "id": "APIPaths", + "type": "array", + "items": { "$ref": "APIPath" }, + "minItems": 1 + }, + { + "id": "APIPath", + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + { + "id": "APIEvents", + "type": "array", + "items": { "$ref": "APIEvent", "onError": "warn" } + }, + { + "id": "APIEvent", + "type": "string", + "enum": ["startup"] + }, + { + "id": "APIParentScope", + "type": "string", + "enum": ["addon_parent", "content_parent", "devtools_parent"] + }, + { + "id": "APIChildScope", + "type": "string", + "enum": ["addon_child", "content_child", "devtools_child"] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/extension.json b/toolkit/components/extensions/schemas/extension.json new file mode 100644 index 0000000000..389b33026b --- /dev/null +++ b/toolkit/components/extensions/schemas/extension.json @@ -0,0 +1,200 @@ +[ + { + "namespace": "extension", + "allowedContexts": ["content", "devtools"], + "description": "The browser.extension API has utilities that can be used by any extension page. It includes support for exchanging messages between an extension and its content scripts or between extensions, as described in detail in $(topic:messaging)[Message Passing].", + "properties": { + "lastError": { + "type": "object", + "optional": true, + "max_manifest_version": 2, + "deprecated": "Please use $(ref:runtime.lastError).", + "allowedContexts": ["content", "devtools"], + "description": "Set for the lifetime of a callback if an ansychronous extension api has resulted in an error. If no error has occured lastError will be undefined.", + "properties": { + "message": { + "type": "string", + "description": "Description of the error that has taken place." + } + }, + "additionalProperties": { + "type": "any" + } + }, + "inIncognitoContext": { + "type": "boolean", + "optional": true, + "allowedContexts": ["content", "devtools"], + "description": "True for content scripts running inside incognito tabs, and for extension pages running inside an incognito process. The latter only applies to extensions with 'split' incognito_behavior." + } + }, + "types": [ + { + "id": "ViewType", + "type": "string", + "enum": ["tab", "popup", "sidebar"], + "description": "The type of extension view." + } + ], + "functions": [ + { + "name": "getURL", + "type": "function", + "deprecated": "Please use $(ref:runtime.getURL).", + "max_manifest_version": 2, + "allowedContexts": ["content", "devtools"], + "description": "Converts a relative path within an extension install directory to a fully-qualified URL.", + "parameters": [ + { + "type": "string", + "name": "path", + "description": "A path to a resource within an extension expressed relative to its install directory." + } + ], + "returns": { + "type": "string", + "description": "The fully-qualified URL to the resource." + } + }, + { + "name": "getViews", + "type": "function", + "description": "Returns an array of the JavaScript 'window' objects for each of the pages running inside the current extension.", + "parameters": [ + { + "type": "object", + "name": "fetchProperties", + "optional": true, + "properties": { + "type": { + "$ref": "ViewType", + "optional": true, + "description": "The type of view to get. If omitted, returns all views (including background pages and tabs). Valid values: 'tab', 'popup', 'sidebar'." + }, + "windowId": { + "type": "integer", + "optional": true, + "description": "The window to restrict the search to. If omitted, returns all views." + }, + "tabId": { + "type": "integer", + "optional": true, + "description": "Find a view according to a tab id. If this field is omitted, returns all views." + } + } + } + ], + "returns": { + "type": "array", + "description": "Array of global objects", + "items": { + "type": "object", + "isInstanceOf": "Window", + "additionalProperties": { "type": "any" } + } + } + }, + { + "name": "getBackgroundPage", + "type": "function", + "description": "Returns the JavaScript 'window' object for the background page running inside the current extension. Returns null if the extension has no background page.", + "parameters": [], + "returns": { + "type": "object", + "optional": true, + "isInstanceOf": "Window", + "additionalProperties": { "type": "any" } + } + }, + { + "name": "isAllowedIncognitoAccess", + "type": "function", + "description": "Retrieves the state of the extension's access to Incognito-mode (as determined by the user-controlled 'Allowed in Incognito' checkbox.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "isAllowedAccess", + "type": "boolean", + "description": "True if the extension has access to Incognito mode, false otherwise." + } + ] + } + ] + }, + { + "name": "isAllowedFileSchemeAccess", + "type": "function", + "description": "Retrieves the state of the extension's access to the 'file://' scheme (as determined by the user-controlled 'Allow access to File URLs' checkbox.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "isAllowedAccess", + "type": "boolean", + "description": "True if the extension can access the 'file://' scheme, false otherwise." + } + ] + } + ] + }, + { + "name": "setUpdateUrlData", + "unsupported": true, + "type": "function", + "description": "Sets the value of the ap CGI parameter used in the extension's update URL. This value is ignored for extensions that are hosted in the browser vendor's store.", + "parameters": [{ "type": "string", "name": "data", "maxLength": 1024 }] + } + ], + "events": [ + { + "name": "onRequest", + "unsupported": true, + "deprecated": "Please use $(ref:runtime.onMessage).", + "type": "function", + "description": "Fired when a request is sent from either an extension process or a content script.", + "parameters": [ + { + "name": "request", + "type": "any", + "optional": true, + "description": "The request sent by the calling script." + }, + { "name": "sender", "$ref": "runtime.MessageSender" }, + { + "name": "sendResponse", + "type": "function", + "description": "Function to call (at most once) when you have a response. The argument should be any JSON-ifiable object, or undefined if there is no response. If you have more than one onRequest listener in the same document, then only one may send a response." + } + ] + }, + { + "name": "onRequestExternal", + "unsupported": true, + "deprecated": "Please use $(ref:runtime.onMessageExternal).", + "type": "function", + "description": "Fired when a request is sent from another extension.", + "parameters": [ + { + "name": "request", + "type": "any", + "optional": true, + "description": "The request sent by the calling script." + }, + { "name": "sender", "$ref": "runtime.MessageSender" }, + { + "name": "sendResponse", + "type": "function", + "description": "Function to call when you have a response. The argument should be any JSON-ifiable object, or undefined if there is no response." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/extension_protocol_handlers.json b/toolkit/components/extensions/schemas/extension_protocol_handlers.json new file mode 100644 index 0000000000..b77e1e7426 --- /dev/null +++ b/toolkit/components/extensions/schemas/extension_protocol_handlers.json @@ -0,0 +1,75 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "id": "ProtocolHandler", + "type": "object", + "description": "Represents a protocol handler definition.", + "properties": { + "name": { + "description": "A user-readable title string for the protocol handler. This will be displayed to the user in interface objects as needed.", + "type": "string" + }, + "protocol": { + "description": "The protocol the site wishes to handle, specified as a string. For example, you can register to handle SMS text message links by registering to handle the \"sms\" scheme.", + "choices": [ + { + "type": "string", + "enum": [ + "bitcoin", + "dat", + "dweb", + "ftp", + "geo", + "gopher", + "im", + "ipfs", + "ipns", + "irc", + "ircs", + "magnet", + "mailto", + "matrix", + "mms", + "news", + "nntp", + "sip", + "sms", + "smsto", + "ssb", + "ssh", + "tel", + "urn", + "webcal", + "wtai", + "xmpp" + ] + }, + { + "type": "string", + "pattern": "^(ext|web)\\+[a-z0-9.+-]+$" + } + ] + }, + "uriTemplate": { + "description": "The URL of the handler, as a string. This string should include \"%s\" as a placeholder which will be replaced with the escaped URL of the document to be handled. This URL might be a true URL, or it could be a phone number, email address, or so forth.", + "preprocess": "localize", + "choices": [{ "$ref": "ExtensionURL" }, { "$ref": "HttpURL" }] + } + } + }, + { + "$extend": "WebExtensionManifest", + "properties": { + "protocol_handlers": { + "description": "A list of protocol handler definitions.", + "optional": true, + "type": "array", + "items": { "$ref": "ProtocolHandler" } + } + } + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/extension_types.json b/toolkit/components/extensions/schemas/extension_types.json new file mode 100644 index 0000000000..fd83cc494a --- /dev/null +++ b/toolkit/components/extensions/schemas/extension_types.json @@ -0,0 +1,164 @@ +[ + { + "namespace": "extensionTypes", + "description": "The browser.extensionTypes API contains type declarations for WebExtensions.", + "types": [ + { + "id": "ImageFormat", + "type": "string", + "enum": ["jpeg", "png"], + "description": "The format of an image." + }, + { + "id": "ImageDetails", + "type": "object", + "description": "Details about the format, quality, area and scale of the capture.", + "properties": { + "format": { + "$ref": "ImageFormat", + "optional": true, + "description": "The format of the resulting image. Default is \"jpeg\"." + }, + "quality": { + "type": "integer", + "optional": true, + "minimum": 0, + "maximum": 100, + "description": "When format is \"jpeg\", controls the quality of the resulting image. This value is ignored for PNG images. As quality is decreased, the resulting image will have more visual artifacts, and the number of bytes needed to store it will decrease." + }, + "rect": { + "type": "object", + "optional": true, + "description": "The area of the document to capture, in CSS pixels, relative to the page. If omitted, capture the visible viewport.", + "properties": { + "x": { "type": "number" }, + "y": { "type": "number" }, + "width": { "type": "number" }, + "height": { "type": "number" } + } + }, + "scale": { + "type": "number", + "optional": true, + "description": "The scale of the resulting image. Defaults to devicePixelRatio." + }, + "resetScrollPosition": { + "type": "boolean", + "optional": true, + "description": "If true, temporarily resets the scroll position of the document to 0. Only takes effect if rect is also specified." + } + } + }, + { + "id": "RunAt", + "type": "string", + "enum": ["document_start", "document_end", "document_idle"], + "description": "The soonest that the JavaScript or CSS will be injected into the tab." + }, + { + "id": "CSSOrigin", + "type": "string", + "enum": ["user", "author"], + "description": "The origin of the CSS to inject, this affects the cascading order (priority) of the stylesheet." + }, + { + "id": "InjectDetails", + "type": "object", + "description": "Details of the script or CSS to inject. Either the code or the file property must be set, but both may not be set at the same time.", + "properties": { + "code": { + "type": "string", + "optional": true, + "description": "JavaScript or CSS code to inject.

Warning:
Be careful using the code parameter. Incorrect use of it may open your extension to cross site scripting attacks." + }, + "file": { + "type": "string", + "optional": true, + "description": "JavaScript or CSS file to inject." + }, + "allFrames": { + "type": "boolean", + "optional": true, + "description": "If allFrames is true, implies that the JavaScript or CSS should be injected into all frames of current page. By default, it's false and is only injected into the top frame." + }, + "matchAboutBlank": { + "type": "boolean", + "optional": true, + "description": "If matchAboutBlank is true, then the code is also injected in about:blank and about:srcdoc frames if your extension has access to its parent document. Code cannot be inserted in top-level about:-frames. By default it is false." + }, + "frameId": { + "type": "integer", + "minimum": 0, + "optional": true, + "description": "The ID of the frame to inject the script into. This may not be used in combination with allFrames." + }, + "runAt": { + "$ref": "RunAt", + "optional": true, + "default": "document_idle", + "description": "The soonest that the JavaScript or CSS will be injected into the tab. Defaults to \"document_idle\"." + }, + "cssOrigin": { + "$ref": "CSSOrigin", + "optional": true, + "description": "The css origin of the stylesheet to inject. Defaults to \"author\"." + } + } + }, + { + "id": "Date", + "choices": [ + { + "type": "string", + "format": "date" + }, + { + "type": "integer", + "minimum": 0 + }, + { + "type": "object", + "isInstanceOf": "Date", + "additionalProperties": { "type": "any" } + } + ] + }, + { + "id": "ExtensionFileOrCode", + "choices": [ + { + "type": "object", + "properties": { + "file": { + "$ref": "manifest.ExtensionURL" + } + } + }, + { + "type": "object", + "properties": { + "code": { + "type": "string" + } + } + } + ] + }, + { + "id": "PlainJSONValue", + "description": "A plain JSON value", + "choices": [ + { "type": "null" }, + { "type": "number" }, + { "type": "string" }, + { "type": "boolean" }, + { "type": "array", "items": { "$ref": "PlainJSONValue" } }, + { + "type": "object", + "additionalProperties": { "$ref": "PlainJSONValue" } + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/geckoProfiler.json b/toolkit/components/extensions/schemas/geckoProfiler.json new file mode 100644 index 0000000000..f2c6bec4cd --- /dev/null +++ b/toolkit/components/extensions/schemas/geckoProfiler.json @@ -0,0 +1,192 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "PermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["geckoProfiler"] + } + ] + } + ] + }, + { + "namespace": "geckoProfiler", + "description": "Exposes the browser's profiler.", + + "permissions": ["geckoProfiler"], + "types": [ + { + "id": "ProfilerFeature", + "type": "string", + "enum": [ + "java", + "js", + "mainthreadio", + "fileio", + "fileioall", + "nomarkerstacks", + "screenshots", + "seqstyle", + "stackwalk", + "jsallocations", + "nostacksampling", + "nativeallocations", + "ipcmessages", + "audiocallbacktracing", + "cpu", + "notimerresolutionchange", + "cpuallthreads", + "samplingallthreads", + "markersallthreads", + "unregisteredthreads", + "processcpu", + "power", + "responsiveness", + "cpufreq", + "bandwidth" + ] + }, + { + "id": "supports", + "type": "string", + "enum": ["windowLength"] + } + ], + "functions": [ + { + "name": "start", + "type": "function", + "description": "Starts the profiler with the specified settings.", + "async": true, + "parameters": [ + { + "name": "settings", + "type": "object", + "properties": { + "bufferSize": { + "type": "integer", + "minimum": 0, + "description": "The maximum size in bytes of the buffer used to store profiling data. A larger value allows capturing a profile that covers a greater amount of time." + }, + "windowLength": { + "type": "number", + "optional": true, + "description": "The length of the window of time that's kept in the buffer. Any collected samples are discarded as soon as they are older than the number of seconds specified in this setting. Zero means no duration restriction." + }, + "interval": { + "type": "number", + "description": "Interval in milliseconds between samples of profiling data. A smaller value will increase the detail of the profiles captured." + }, + "features": { + "type": "array", + "description": "A list of active features for the profiler.", + "items": { + "$ref": "ProfilerFeature" + } + }, + "threads": { + "type": "array", + "description": "A list of thread names for which to capture profiles.", + "optional": true, + "items": { + "type": "string" + } + } + } + } + ] + }, + { + "name": "stop", + "type": "function", + "description": "Stops the profiler and discards any captured profile data.", + "async": true, + "parameters": [] + }, + { + "name": "pause", + "type": "function", + "description": "Pauses the profiler, keeping any profile data that is already written.", + "async": true, + "parameters": [] + }, + { + "name": "resume", + "type": "function", + "description": "Resumes the profiler with the settings that were initially used to start it.", + "async": true, + "parameters": [] + }, + { + "name": "dumpProfileToFile", + "type": "function", + "description": "Gathers the profile data from the current profiling session, and writes it to disk. The returned promise resolves to a path that locates the created file.", + "async": true, + "parameters": [ + { + "type": "string", + "name": "fileName", + "description": "The name of the file inside the profile/profiler directory" + } + ] + }, + { + "name": "getProfile", + "type": "function", + "description": "Gathers the profile data from the current profiling session.", + "async": true, + "parameters": [] + }, + { + "name": "getProfileAsArrayBuffer", + "type": "function", + "description": "Gathers the profile data from the current profiling session. The returned promise resolves to an array buffer that contains a JSON string.", + "async": true, + "parameters": [] + }, + { + "name": "getProfileAsGzippedArrayBuffer", + "type": "function", + "description": "Gathers the profile data from the current profiling session. The returned promise resolves to an array buffer that contains a gzipped JSON string.", + "async": true, + "parameters": [] + }, + { + "name": "getSymbols", + "type": "function", + "description": "Gets the debug symbols for a particular library.", + "async": true, + "parameters": [ + { + "type": "string", + "name": "debugName", + "description": "The name of the library's debug file. For example, 'xul.pdb" + }, + { + "type": "string", + "name": "breakpadId", + "description": "The Breakpad ID of the library" + } + ] + } + ], + "events": [ + { + "name": "onRunning", + "type": "function", + "description": "Fires when the profiler starts/stops running.", + "parameters": [ + { + "name": "isRunning", + "type": "boolean", + "description": "Whether the profiler is running or not. Pausing the profiler will not affect this value." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/i18n.json b/toolkit/components/extensions/schemas/i18n.json new file mode 100644 index 0000000000..f86efb98c0 --- /dev/null +++ b/toolkit/components/extensions/schemas/i18n.json @@ -0,0 +1,139 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "WebExtensionManifest", + "properties": { + "default_locale": { + "type": "string", + "optional": "true" + }, + "l10n_resources": { + "type": "array", + "items": { + "type": "string" + }, + "optional": true, + "privileged": true + } + } + } + ] + }, + { + "namespace": "i18n", + "allowedContexts": ["content", "devtools"], + "defaultContexts": ["content", "devtools"], + "description": "Use the browser.i18n infrastructure to implement internationalization across your whole app or extension.", + "types": [ + { + "id": "LanguageCode", + "type": "string", + "description": "An ISO language code such as en or fr. For a complete list of languages supported by this method, see kLanguageInfoTable. For an unknown language, und will be returned, which means that [percentage] of the text is unknown to CLD" + } + ], + "functions": [ + { + "name": "getAcceptLanguages", + "type": "function", + "description": "Gets the accept-languages of the browser. This is different from the locale used by the browser; to get the locale, use $(ref:i18n.getUILanguage).", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "languages", + "type": "array", + "items": { "$ref": "LanguageCode" }, + "description": "Array of LanguageCode" + } + ] + } + ] + }, + { + "name": "getMessage", + "type": "function", + "description": "Gets the localized string for the specified message. If the message is missing, this method returns an empty string (''). If the format of the getMessage() call is wrong — for example, messageName is not a string or the substitutions array has more than 9 elements — this method returns undefined.", + "parameters": [ + { + "type": "string", + "name": "messageName", + "description": "The name of the message, as specified in the $(topic:i18n-messages)[messages.json] file." + }, + { + "type": "any", + "name": "substitutions", + "optional": true, + "description": "Substitution strings, if the message requires any." + } + ], + "returns": { + "type": "string", + "description": "Message localized for current locale." + } + }, + { + "name": "getUILanguage", + "type": "function", + "description": "Gets the browser UI language of the browser. This is different from $(ref:i18n.getAcceptLanguages) which returns the preferred user languages.", + "parameters": [], + "returns": { + "type": "string", + "description": "The browser UI language code such as en-US or fr-FR." + } + }, + { + "name": "detectLanguage", + "type": "function", + "description": "Detects the language of the provided text using CLD.", + "async": "callback", + "parameters": [ + { + "type": "string", + "name": "text", + "description": "User input string to be translated." + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "type": "object", + "name": "result", + "description": "LanguageDetectionResult object that holds detected langugae reliability and array of DetectedLanguage", + "properties": { + "isReliable": { + "type": "boolean", + "description": "CLD detected language reliability" + }, + "languages": { + "type": "array", + "description": "array of detectedLanguage", + "items": { + "type": "object", + "description": "DetectedLanguage object that holds detected ISO language code and its percentage in the input string", + "properties": { + "language": { + "$ref": "LanguageCode" + }, + "percentage": { + "type": "integer", + "description": "The percentage of the detected language" + } + } + } + } + } + } + ] + } + ] + } + ], + "events": [] + } +] diff --git a/toolkit/components/extensions/schemas/identity.json b/toolkit/components/extensions/schemas/identity.json new file mode 100644 index 0000000000..947630aa8c --- /dev/null +++ b/toolkit/components/extensions/schemas/identity.json @@ -0,0 +1,219 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "PermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["identity"] + } + ] + } + ] + }, + { + "namespace": "identity", + "description": "Use the chrome.identity API to get OAuth2 access tokens. ", + "permissions": ["identity"], + "types": [ + { + "id": "AccountInfo", + "type": "object", + "description": "An object encapsulating an OAuth account id.", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for the account. This ID will not change for the lifetime of the account. " + } + } + } + ], + "functions": [ + { + "name": "getAccounts", + "type": "function", + "unsupported": true, + "description": "Retrieves a list of AccountInfo objects describing the accounts present on the profile.", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "results", + "type": "array", + "items": { + "$ref": "AccountInfo" + } + } + ] + } + ] + }, + { + "name": "getAuthToken", + "type": "function", + "unsupported": true, + "description": "Gets an OAuth2 access token using the client ID and scopes specified in the oauth2 section of manifest.json.", + "async": "callback", + "parameters": [ + { + "name": "details", + "optional": true, + "type": "object", + "properties": { + "interactive": { + "optional": true, + "type": "boolean" + }, + "account": { + "optional": true, + "$ref": "AccountInfo" + }, + "scopes": { + "optional": true, + "type": "array", + "items": { + "type": "string" + } + } + } + }, + { + "name": "callback", + "optional": true, + "type": "function", + "parameters": [ + { + "name": "results", + "type": "array", + "items": { + "$ref": "AccountInfo" + } + } + ] + } + ] + }, + { + "name": "getProfileUserInfo", + "type": "function", + "unsupported": true, + "description": "Retrieves email address and obfuscated gaia id of the user signed into a profile.", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "userinfo", + "type": "object", + "properties": { + "email": { "type": "string" }, + "id": { "type": "string" } + } + } + ] + } + ] + }, + { + "name": "removeCachedAuthToken", + "type": "function", + "unsupported": true, + "description": "Removes an OAuth2 access token from the Identity API's token cache.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "properties": { + "token": { "type": "string" } + } + }, + { + "name": "callback", + "optional": true, + "type": "function", + "parameters": [ + { + "name": "userinfo", + "type": "object", + "properties": { + "email": { "type": "string" }, + "id": { "type": "string" } + } + } + ] + } + ] + }, + { + "name": "launchWebAuthFlow", + "type": "function", + "description": "Starts an auth flow at the specified URL.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "properties": { + "url": { "$ref": "manifest.HttpURL" }, + "interactive": { "type": "boolean", "optional": true } + } + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": " responseUrl", + "type": "string", + "optional": true + } + ] + } + ] + }, + { + "name": "getRedirectURL", + "type": "function", + "description": "Generates a redirect URL to be used in |launchWebAuthFlow|.", + "parameters": [ + { + "name": "path", + "type": "string", + "default": "", + "optional": true, + "description": "The path appended to the end of the generated URL. " + } + ], + "returns": { + "type": "string" + } + } + ], + "events": [ + { + "name": "onSignInChanged", + "unsupported": true, + "type": "function", + "description": "Fired when signin state changes for an account on the user's profile.", + "parameters": [ + { + "name": "account", + "$ref": "AccountInfo" + }, + { + "name": "signedIn", + "type": "boolean" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/idle.json b/toolkit/components/extensions/schemas/idle.json new file mode 100644 index 0000000000..69dd063058 --- /dev/null +++ b/toolkit/components/extensions/schemas/idle.json @@ -0,0 +1,66 @@ +[ + { + "namespace": "idle", + "description": "Use the browser.idle API to detect when the machine's idle state changes.", + "permissions": ["idle"], + "types": [ + { + "id": "IdleState", + "type": "string", + "enum": ["active", "idle"] + } + ], + "functions": [ + { + "name": "queryState", + "type": "function", + "description": "Returns \"idle\" if the user has not generated any input for a specified number of seconds, or \"active\" otherwise.", + "async": "callback", + "parameters": [ + { + "name": "detectionIntervalInSeconds", + "type": "integer", + "minimum": 15, + "description": "The system is considered idle if detectionIntervalInSeconds seconds have elapsed since the last user input detected." + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "newState", + "$ref": "IdleState" + } + ] + } + ] + }, + { + "name": "setDetectionInterval", + "type": "function", + "description": "Sets the interval, in seconds, used to determine when the system is in an idle state for onStateChanged events. The default interval is 60 seconds.", + "parameters": [ + { + "name": "intervalInSeconds", + "type": "integer", + "minimum": 15, + "description": "Threshold, in seconds, used to determine when the system is in an idle state." + } + ] + } + ], + "events": [ + { + "name": "onStateChanged", + "type": "function", + "description": "Fired when the system changes to an active or idle state. The event fires with \"idle\" if the the user has not generated any input for a specified number of seconds, and \"active\" when the user generates input on an idle system.", + "parameters": [ + { + "name": "newState", + "$ref": "IdleState" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/jar.mn b/toolkit/components/extensions/schemas/jar.mn new file mode 100644 index 0000000000..59e2332ebe --- /dev/null +++ b/toolkit/components/extensions/schemas/jar.mn @@ -0,0 +1,54 @@ +# 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/. + +toolkit.jar: +% content extensions %content/extensions/ + content/extensions/schemas/activity_log.json + content/extensions/schemas/alarms.json + content/extensions/schemas/browser_action.json + content/extensions/schemas/browser_settings.json + content/extensions/schemas/browsing_data.json +#ifndef ANDROID + content/extensions/schemas/captive_portal.json +#endif + content/extensions/schemas/clipboard.json + content/extensions/schemas/content_scripts.json + content/extensions/schemas/contextual_identities.json + content/extensions/schemas/cookies.json + content/extensions/schemas/declarative_net_request.json + content/extensions/schemas/dns.json + content/extensions/schemas/downloads.json + content/extensions/schemas/events.json + content/extensions/schemas/experiments.json + content/extensions/schemas/extension.json + content/extensions/schemas/extension_types.json + content/extensions/schemas/extension_protocol_handlers.json +#ifndef ANDROID + content/extensions/schemas/geckoProfiler.json +#endif + content/extensions/schemas/i18n.json +#ifndef ANDROID + content/extensions/schemas/identity.json +#endif + content/extensions/schemas/idle.json + content/extensions/schemas/management.json + content/extensions/schemas/manifest.json + content/extensions/schemas/native_manifest.json + content/extensions/schemas/network_status.json + content/extensions/schemas/notifications.json + content/extensions/schemas/page_action.json + content/extensions/schemas/permissions.json + content/extensions/schemas/proxy.json + content/extensions/schemas/privacy.json + content/extensions/schemas/runtime.json + content/extensions/schemas/scripting.json + content/extensions/schemas/storage.json + content/extensions/schemas/telemetry.json + content/extensions/schemas/test.json + content/extensions/schemas/theme.json + content/extensions/schemas/types.json + content/extensions/schemas/user_scripts.json + content/extensions/schemas/user_scripts_content.json + content/extensions/schemas/web_navigation.json + content/extensions/schemas/web_request.json diff --git a/toolkit/components/extensions/schemas/management.json b/toolkit/components/extensions/schemas/management.json new file mode 100644 index 0000000000..b75c0e2b4c --- /dev/null +++ b/toolkit/components/extensions/schemas/management.json @@ -0,0 +1,364 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermission", + "choices": [ + { + "type": "string", + "enum": ["management"] + } + ] + } + ] + }, + { + "namespace": "management", + "description": "The browser.management API provides ways to manage the list of extensions that are installed and running.", + "types": [ + { + "id": "IconInfo", + "description": "Information about an icon belonging to an extension.", + "type": "object", + "properties": { + "size": { + "type": "integer", + "description": "A number representing the width and height of the icon. Likely values include (but are not limited to) 128, 48, 24, and 16." + }, + "url": { + "type": "string", + "description": "The URL for this icon image. To display a grayscale version of the icon (to indicate that an extension is disabled, for example), append ?grayscale=true to the URL." + } + } + }, + { + "id": "ExtensionDisabledReason", + "description": "A reason the item is disabled.", + "type": "string", + "enum": ["unknown", "permissions_increase"] + }, + { + "id": "ExtensionType", + "description": "The type of this extension, 'extension' or 'theme'.", + "type": "string", + "enum": ["extension", "theme"] + }, + { + "id": "ExtensionInstallType", + "description": "How the extension was installed. One of
development: The extension was loaded unpacked in developer mode,
normal: The extension was installed normally via an .xpi file,
sideload: The extension was installed by other software on the machine,
other: The extension was installed by other means.", + "type": "string", + "enum": ["development", "normal", "sideload", "other"] + }, + { + "id": "ExtensionInfo", + "description": "Information about an installed extension.", + "type": "object", + "properties": { + "id": { + "description": "The extension's unique identifier.", + "type": "string" + }, + "name": { + "description": "The name of this extension.", + "type": "string" + }, + "shortName": { + "description": "A short version of the name of this extension.", + "type": "string", + "optional": true + }, + "description": { + "description": "The description of this extension.", + "type": "string" + }, + "version": { + "description": "The version of this extension.", + "type": "string" + }, + "versionName": { + "description": "The version name of this extension if the manifest specified one.", + "type": "string", + "optional": true + }, + "mayDisable": { + "description": "Whether this extension can be disabled or uninstalled by the user.", + "type": "boolean" + }, + "enabled": { + "description": "Whether it is currently enabled or disabled.", + "type": "boolean" + }, + "disabledReason": { + "description": "A reason the item is disabled.", + "$ref": "ExtensionDisabledReason", + "optional": true + }, + "type": { + "description": "The type of this extension, 'extension' or 'theme'.", + "$ref": "ExtensionType" + }, + "homepageUrl": { + "description": "The URL of the homepage of this extension.", + "type": "string", + "optional": true + }, + "updateUrl": { + "description": "The update URL of this extension.", + "type": "string", + "optional": true + }, + "optionsUrl": { + "description": "The url for the item's options page, if it has one.", + "type": "string" + }, + "icons": { + "description": "A list of icon information. Note that this just reflects what was declared in the manifest, and the actual image at that url may be larger or smaller than what was declared, so you might consider using explicit width and height attributes on img tags referencing these images. See the manifest documentation on icons for more details.", + "type": "array", + "optional": true, + "items": { + "$ref": "IconInfo" + } + }, + "permissions": { + "description": "Returns a list of API based permissions.", + "type": "array", + "optional": true, + "items": { + "type": "string" + } + }, + "hostPermissions": { + "description": "Returns a list of host based permissions.", + "type": "array", + "optional": true, + "items": { + "type": "string" + } + }, + "installType": { + "description": "How the extension was installed.", + "$ref": "ExtensionInstallType" + } + } + } + ], + "functions": [ + { + "name": "getAll", + "type": "function", + "permissions": ["management"], + "description": "Returns a list of information about installed extensions.", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [ + { + "type": "array", + "name": "result", + "items": { + "$ref": "ExtensionInfo" + } + } + ] + } + ] + }, + { + "name": "get", + "type": "function", + "permissions": ["management"], + "description": "Returns information about the installed extension that has the given ID.", + "async": "callback", + "parameters": [ + { + "name": "id", + "$ref": "manifest.ExtensionID", + "description": "The ID from an item of $(ref:management.ExtensionInfo)." + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [ + { + "name": "result", + "$ref": "ExtensionInfo" + } + ] + } + ] + }, + { + "name": "install", + "type": "function", + "requireUserInput": true, + "permissions": ["management"], + "description": "Installs and enables a theme extension from the given url.", + "async": "callback", + "parameters": [ + { + "name": "options", + "type": "object", + "properties": { + "url": { + "$ref": "manifest.HttpURL", + "description": "URL pointing to the XPI file on addons.mozilla.org or similar." + }, + "hash": { + "type": "string", + "optional": true, + "pattern": "^(sha256|sha512):[0-9a-fA-F]{64,128}$", + "description": "A hash of the XPI file, using sha256 or stronger." + } + } + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [ + { + "name": "result", + "type": "object", + "properties": { + "id": { + "$ref": "manifest.ExtensionID" + } + } + } + ] + } + ] + }, + { + "name": "getSelf", + "type": "function", + "description": "Returns information about the calling extension. Note: This function can be used without requesting the 'management' permission in the manifest.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "result", + "$ref": "ExtensionInfo" + } + ] + } + ] + }, + { + "name": "uninstallSelf", + "type": "function", + "description": "Uninstalls the calling extension. Note: This function can be used without requesting the 'management' permission in the manifest.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "options", + "optional": true, + "properties": { + "showConfirmDialog": { + "type": "boolean", + "optional": true, + "description": "Whether or not a confirm-uninstall dialog should prompt the user. Defaults to false." + }, + "dialogMessage": { + "type": "string", + "optional": true, + "description": "The message to display to a user when being asked to confirm removal of the extension." + } + } + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "setEnabled", + "type": "function", + "permissions": ["management"], + "description": "Enables or disables the given add-on.", + "async": "callback", + "parameters": [ + { + "name": "id", + "type": "string", + "description": "ID of the add-on to enable/disable." + }, + { + "name": "enabled", + "type": "boolean", + "description": "Whether to enable or disable the add-on." + }, + { + "name": "callback", + "type": "function", + "optional": true, + "parameters": [] + } + ] + } + ], + "events": [ + { + "name": "onDisabled", + "type": "function", + "permissions": ["management"], + "description": "Fired when an addon has been disabled.", + "parameters": [ + { + "name": "info", + "$ref": "ExtensionInfo" + } + ] + }, + { + "name": "onEnabled", + "type": "function", + "permissions": ["management"], + "description": "Fired when an addon has been enabled.", + "parameters": [ + { + "name": "info", + "$ref": "ExtensionInfo" + } + ] + }, + { + "name": "onInstalled", + "type": "function", + "permissions": ["management"], + "description": "Fired when an addon has been installed.", + "parameters": [ + { + "name": "info", + "$ref": "ExtensionInfo" + } + ] + }, + { + "name": "onUninstalled", + "type": "function", + "permissions": ["management"], + "description": "Fired when an addon has been uninstalled.", + "parameters": [ + { + "name": "info", + "$ref": "ExtensionInfo" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/manifest.json b/toolkit/components/extensions/schemas/manifest.json new file mode 100644 index 0000000000..14f78ba564 --- /dev/null +++ b/toolkit/components/extensions/schemas/manifest.json @@ -0,0 +1,782 @@ +[ + { + "namespace": "manifest", + "permissions": [], + "types": [ + { + "id": "ManifestBase", + "type": "object", + "description": "Common properties for all manifest.json files", + "properties": { + "manifest_version": { + "type": "integer", + "minimum": 2, + "maximum": 3, + "postprocess": "manifestVersionCheck" + }, + + "applications": { + "$ref": "DeprecatedApplications", + "description": "The applications property is deprecated, please use 'browser_specific_settings'", + "optional": true, + "max_manifest_version": 2 + }, + + "browser_specific_settings": { + "$ref": "BrowserSpecificSettings", + "optional": true + }, + + "name": { + "type": "string", + "optional": false, + "preprocess": "localize" + }, + + "short_name": { + "type": "string", + "optional": true, + "preprocess": "localize" + }, + + "description": { + "type": "string", + "optional": true, + "preprocess": "localize" + }, + + "author": { + "type": "string", + "optional": true, + "preprocess": "localize", + "onError": "warn" + }, + + "version": { + "type": "string", + "optional": false, + "format": "versionString" + }, + + "homepage_url": { + "type": "string", + "format": "url", + "optional": true, + "preprocess": "localize" + }, + + "install_origins": { + "type": "array", + "optional": true, + "items": { + "type": "string", + "format": "origin" + } + }, + + "developer": { + "type": "object", + "optional": true, + "properties": { + "name": { + "type": "string", + "optional": true, + "preprocess": "localize" + }, + "url": { + "type": "string", + "format": "url", + "optional": true, + "preprocess": "localize", + "onError": "warn" + } + } + } + } + }, + { + "id": "WebExtensionManifest", + "type": "object", + "description": "Represents a WebExtension manifest.json file", + + "$import": "ManifestBase", + "properties": { + "minimum_chrome_version": { + "type": "string", + "optional": true + }, + + "minimum_opera_version": { + "type": "string", + "optional": true + }, + + "icons": { + "type": "object", + "optional": true, + "patternProperties": { + "^[1-9]\\d*$": { "$ref": "ExtensionFileUrl" } + } + }, + + "incognito": { + "type": "string", + "enum": ["not_allowed", "spanning"], + "default": "spanning", + "optional": true + }, + + "background": { + "choices": [ + { + "type": "object", + "properties": { + "page": { "$ref": "ExtensionURL" }, + "persistent": { + "optional": true, + "type": "boolean", + "max_manifest_version": 2, + "default": true + } + }, + "additionalProperties": { "$ref": "UnrecognizedProperty" } + }, + { + "type": "object", + "properties": { + "scripts": { + "type": "array", + "items": { "$ref": "ExtensionURL" } + }, + "type": { + "optional": true, + "type": "string", + "enum": ["module", "classic"] + }, + "persistent": { + "optional": true, + "type": "boolean", + "max_manifest_version": 2, + "default": true + } + }, + "additionalProperties": { "$ref": "UnrecognizedProperty" } + }, + { + "type": "object", + "properties": { + "service_worker": { "$ref": "ExtensionURL" } + }, + "postprocess": "requireBackgroundServiceWorkerEnabled" + } + ], + "optional": true + }, + + "options_ui": { + "type": "object", + + "optional": true, + + "properties": { + "page": { "$ref": "ExtensionURL" }, + "browser_style": { + "type": "boolean", + "optional": true, + "description": "Defaults to true in Manifest V2; Deprecated in Manifest V3." + }, + "chrome_style": { + "type": "boolean", + "optional": true, + "max_manifest_version": 2, + "description": "chrome_style is ignored in Firefox. Its replacement (browser_style) has been deprecated." + }, + "open_in_tab": { + "type": "boolean", + "optional": true + } + }, + "additionalProperties": { "$ref": "UnrecognizedProperty" } + }, + + "content_scripts": { + "type": "array", + "optional": true, + "items": { "$ref": "ContentScript" } + }, + + "content_security_policy": { + "optional": true, + "onError": "warn", + "choices": [ + { + "max_manifest_version": 2, + "type": "string", + "format": "contentSecurityPolicy" + }, + { + "min_manifest_version": 3, + "type": "object", + "additionalProperties": { + "$ref": "UnrecognizedProperty" + }, + "properties": { + "extension_pages": { + "type": "string", + "optional": true, + "format": "contentSecurityPolicy", + "description": "The Content Security Policy used for extension pages." + } + } + } + ] + }, + + "permissions": { + "default": [], + "optional": true, + "choices": [ + { + "max_manifest_version": 2, + "type": "array", + "items": { + "$ref": "PermissionOrOrigin", + "onError": "warn" + } + }, + { + "min_manifest_version": 3, + "type": "array", + "items": { + "$ref": "Permission", + "onError": "warn" + } + } + ] + }, + + "granted_host_permissions": { + "type": "boolean", + "optional": true, + "default": false + }, + + "host_permissions": { + "min_manifest_version": 3, + "type": "array", + "items": { + "$ref": "MatchPattern", + "onError": "warn" + }, + "optional": true, + "default": [] + }, + + "optional_permissions": { + "type": "array", + "items": { + "$ref": "OptionalPermissionOrOrigin", + "onError": "warn" + }, + "optional": true, + "default": [] + }, + + "web_accessible_resources": { + "optional": true, + "choices": [ + { + "max_manifest_version": 2, + "type": "array", + "items": { "type": "string" } + }, + { + "min_manifest_version": 3, + "type": "array", + "postprocess": "webAccessibleMatching", + "items": { + "type": "object", + "properties": { + "resources": { + "type": "array", + "items": { "type": "string" } + }, + "matches": { + "optional": true, + "type": "array", + "items": { "$ref": "MatchPattern" } + }, + "extension_ids": { + "optional": true, + "type": "array", + "items": { + "choices": [ + { "$ref": "ExtensionID" }, + { "type": "string", "enum": ["*"] } + ] + } + } + }, + "additionalProperties": { "$ref": "UnrecognizedProperty" } + } + } + ] + }, + + "hidden": { + "type": "boolean", + "optional": true, + "default": false + } + }, + + "additionalProperties": { "$ref": "UnrecognizedProperty" } + }, + { + "id": "WebExtensionLangpackManifest", + "type": "object", + "description": "Represents a WebExtension language pack manifest.json file", + + "$import": "ManifestBase", + "properties": { + "langpack_id": { + "type": "string", + "pattern": "^[a-zA-Z][a-zA-Z-]+$" + }, + + "languages": { + "type": "object", + "patternProperties": { + "^[a-z]{2}[a-zA-Z-]*$": { + "type": "object", + "properties": { + "chrome_resources": { + "type": "object", + "patternProperties": { + "^[a-zA-Z-.]+$": { + "choices": [ + { + "$ref": "ExtensionURL" + }, + { + "type": "object", + "patternProperties": { + "^[a-z]+$": { + "$ref": "ExtensionURL" + } + } + } + ] + } + } + }, + "version": { + "type": "string" + } + } + } + } + }, + "sources": { + "type": "object", + "optional": true, + "patternProperties": { + "^[a-z]+$": { + "type": "object", + "properties": { + "base_path": { + "$ref": "ExtensionURL" + }, + "paths": { + "type": "array", + "items": { + "type": "string", + "format": "strictRelativeUrl" + }, + "optional": true + } + } + } + } + } + } + }, + { + "id": "WebExtensionDictionaryManifest", + "type": "object", + "description": "Represents a WebExtension dictionary manifest.json file", + + "$import": "ManifestBase", + "properties": { + "dictionaries": { + "type": "object", + "patternProperties": { + "^[a-z]{2}[a-zA-Z-]*$": { + "type": "string", + "format": "strictRelativeUrl", + "pattern": "\\.dic$" + } + } + } + } + }, + { + "id": "WebExtensionSitePermissionsManifest", + "type": "object", + "description": "Represents a WebExtension site permissions manifest.json file", + + "$import": "ManifestBase", + "properties": { + "site_permissions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "SitePermission" + } + }, + "install_origins": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "type": "string", + "format": "origin" + } + } + } + }, + { + "id": "ThemeIcons", + "type": "object", + "properties": { + "light": { + "$ref": "ExtensionURL", + "description": "A light icon to use for dark themes" + }, + "dark": { + "$ref": "ExtensionURL", + "description": "The dark icon to use for light themes" + }, + "size": { + "type": "integer", + "description": "The size of the icons" + } + }, + "additionalProperties": { "$ref": "UnrecognizedProperty" } + }, + { + "id": "OptionalPermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["idle"] + } + ] + }, + { + "id": "OptionalPermission", + "choices": [ + { "$ref": "OptionalPermissionNoPrompt" }, + { + "type": "string", + "enum": [ + "clipboardRead", + "clipboardWrite", + "geolocation", + "notifications" + ] + } + ] + }, + { + "id": "OptionalPermissionOrOrigin", + "choices": [ + { "$ref": "OptionalPermission" }, + { "$ref": "MatchPattern" } + ] + }, + { + "id": "PermissionPrivileged", + "choices": [ + { + "type": "string", + "enum": ["mozillaAddons"] + } + ] + }, + { + "id": "PermissionNoPrompt", + "choices": [ + { "$ref": "OptionalPermissionNoPrompt" }, + { "$ref": "PermissionPrivileged" }, + { + "type": "string", + "enum": ["alarms", "storage", "unlimitedStorage"] + } + ] + }, + { + "id": "Permission", + "choices": [ + { "$ref": "PermissionNoPrompt" }, + { "$ref": "OptionalPermission" } + ] + }, + { + "id": "PermissionOrOrigin", + "choices": [{ "$ref": "Permission" }, { "$ref": "MatchPattern" }] + }, + { + "id": "SitePermission", + "choices": [ + { + "type": "string", + "enum": ["midi", "midi-sysex"] + } + ] + }, + { + "id": "HttpURL", + "type": "string", + "format": "url", + "pattern": "^https?://.*$" + }, + { + "id": "ExtensionURL", + "type": "string", + "format": "strictRelativeUrl" + }, + { + "id": "ExtensionFileUrl", + "type": "string", + "format": "strictRelativeUrl", + "pattern": "\\S", + "preprocess": "localize" + }, + { + "id": "ImageDataOrExtensionURL", + "type": "string", + "format": "imageDataOrStrictRelativeUrl" + }, + { + "id": "ExtensionID", + "choices": [ + { + "type": "string", + "pattern": "(?i)^\\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\}$" + }, + { + "type": "string", + "pattern": "(?i)^[a-z0-9-._]*@[a-z0-9-._]+$" + } + ] + }, + { + "id": "FirefoxSpecificProperties", + "type": "object", + "properties": { + "id": { + "$ref": "ExtensionID", + "optional": true + }, + + "update_url": { + "type": "string", + "format": "url", + "optional": true + }, + + "strict_min_version": { + "type": "string", + "optional": true + }, + + "strict_max_version": { + "type": "string", + "optional": true + } + } + }, + { + "id": "GeckoAndroidSpecificProperties", + "type": "object", + "properties": { + "strict_min_version": { + "type": "string", + "optional": true + }, + "strict_max_version": { + "type": "string", + "optional": true + } + }, + "additionalProperties": { "type": "any" } + }, + { + "id": "DeprecatedApplications", + "type": "object", + "properties": { + "gecko": { + "$ref": "FirefoxSpecificProperties", + "optional": true + }, + "gecko_android": { + "$ref": "GeckoAndroidSpecificProperties", + "optional": true, + "unsupported": true + } + }, + "additionalProperties": { "type": "any" } + }, + { + "id": "BrowserSpecificSettings", + "type": "object", + "properties": { + "gecko": { + "$ref": "FirefoxSpecificProperties", + "optional": true + }, + "gecko_android": { + "$ref": "GeckoAndroidSpecificProperties", + "optional": true + } + }, + "additionalProperties": { "type": "any" } + }, + { + "id": "MatchPattern", + "choices": [ + { + "type": "string", + "enum": [""] + }, + { + "$ref": "MatchPatternRestricted" + }, + { + "$ref": "MatchPatternUnestricted" + } + ] + }, + { + "id": "MatchPatternRestricted", + "description": "Same as MatchPattern above, but excludes ", + "choices": [ + { + "type": "string", + "pattern": "^(https?|wss?|file|ftp|\\*)://(\\*|\\*\\.[^*/]+|[^*/]+)/.*$" + }, + { + "type": "string", + "pattern": "^file:///.*$" + } + ] + }, + { + "id": "MatchPatternUnestricted", + "description": "Mostly unrestricted match patterns for privileged add-ons. This should technically be rejected for unprivileged add-ons, but, reasons. The MatchPattern class will still refuse privileged schemes for those extensions.", + "choices": [ + { + "type": "string", + "pattern": "^resource://(\\*|\\*\\.[^*/]+|[^*/]+)/.*$|^about:" + } + ] + }, + { + "id": "ContentScript", + "type": "object", + "description": "Details of the script or CSS to inject. Either the code or the file property must be set, but both may not be set at the same time. Based on InjectDetails, but using underscore rather than camel case naming conventions.", + "additionalProperties": { "$ref": "UnrecognizedProperty" }, + "properties": { + "matches": { + "type": "array", + "optional": false, + "minItems": 1, + "items": { "$ref": "MatchPattern" } + }, + "exclude_matches": { + "type": "array", + "optional": true, + "minItems": 1, + "items": { "$ref": "MatchPattern" } + }, + "include_globs": { + "type": "array", + "optional": true, + "items": { "type": "string" } + }, + "exclude_globs": { + "type": "array", + "optional": true, + "items": { "type": "string" } + }, + "css": { + "type": "array", + "optional": true, + "description": "The list of CSS files to inject", + "items": { "$ref": "ExtensionURL" } + }, + "js": { + "type": "array", + "optional": true, + "description": "The list of JS files to inject", + "items": { "$ref": "ExtensionURL" } + }, + "all_frames": { + "type": "boolean", + "optional": true, + "description": "If allFrames is true, implies that the JavaScript or CSS should be injected into all frames of current page. By default, it's false and is only injected into the top frame." + }, + "match_about_blank": { + "type": "boolean", + "optional": true, + "description": "If matchAboutBlank is true, then the code is also injected in about:blank and about:srcdoc frames if your extension has access to its parent document. Code cannot be inserted in top-level about:-frames. By default it is false." + }, + "run_at": { + "$ref": "extensionTypes.RunAt", + "optional": true, + "default": "document_idle", + "description": "The soonest that the JavaScript or CSS will be injected into the tab. Defaults to \"document_idle\"." + } + } + }, + { + "id": "IconPath", + "choices": [ + { + "type": "object", + "patternProperties": { + "^[1-9]\\d*$": { "$ref": "ExtensionFileUrl" } + }, + "additionalProperties": false + }, + { "$ref": "ExtensionFileUrl" } + ] + }, + { + "id": "IconImageData", + "choices": [ + { + "type": "object", + "patternProperties": { + "^[1-9]\\d*$": { "$ref": "ImageData" } + }, + "additionalProperties": false + }, + { "$ref": "ImageData" } + ] + }, + { + "id": "ImageData", + "type": "object", + "isInstanceOf": "ImageData", + "postprocess": "convertImageDataToURL" + }, + { + "id": "UnrecognizedProperty", + "type": "any", + "deprecated": "An unexpected property was found in the WebExtension manifest." + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/moz.build b/toolkit/components/extensions/schemas/moz.build new file mode 100644 index 0000000000..d988c0ff9b --- /dev/null +++ b/toolkit/components/extensions/schemas/moz.build @@ -0,0 +1,7 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +JAR_MANIFESTS += ["jar.mn"] diff --git a/toolkit/components/extensions/schemas/native_manifest.json b/toolkit/components/extensions/schemas/native_manifest.json new file mode 100644 index 0000000000..b08262e59b --- /dev/null +++ b/toolkit/components/extensions/schemas/native_manifest.json @@ -0,0 +1,60 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "id": "NativeManifest", + "description": "Represents a native manifest file", + "choices": [ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^\\w+(\\.\\w+)*$" + }, + "description": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pkcs11", "stdio"] + }, + "allowed_extensions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "manifest.ExtensionID" + } + } + } + }, + { + "type": "object", + "properties": { + "name": { + "$ref": "manifest.ExtensionID" + }, + "description": { + "type": "string" + }, + "data": { + "type": "object", + "additionalProperties": { + "type": "any" + } + }, + "type": { + "type": "string", + "enum": ["storage"] + } + } + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/network_status.json b/toolkit/components/extensions/schemas/network_status.json new file mode 100644 index 0000000000..bf2c9cb494 --- /dev/null +++ b/toolkit/components/extensions/schemas/network_status.json @@ -0,0 +1,66 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "PermissionPrivileged", + "choices": [ + { + "type": "string", + "enum": ["networkStatus"] + } + ] + } + ] + }, + { + "namespace": "networkStatus", + "description": "This API provides the ability to determine the status of and detect changes in the network connection. This API can only be used in privileged extensions.", + "permissions": ["networkStatus"], + "types": [ + { + "id": "NetworkLinkInfo", + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["unknown", "up", "down"], + "description": "Status of the network link, if \"unknown\" then link is usually assumed to be \"up\"" + }, + "type": { + "type": "string", + "enum": ["unknown", "ethernet", "usb", "wifi", "wimax", "mobile"], + "description": "If known, the type of network connection that is avialable." + }, + "id": { + "type": "string", + "optional": true, + "description": "If known, the network id or name." + } + } + } + ], + "functions": [ + { + "name": "getLinkInfo", + "type": "function", + "description": "Returns the $(ref:NetworkLinkInfo} of the current network connection.", + "async": true, + "parameters": [] + } + ], + "events": [ + { + "name": "onConnectionChanged", + "type": "function", + "description": "Fired when the network connection state changes.", + "parameters": [ + { + "name": "details", + "$ref": "NetworkLinkInfo" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/notifications.json b/toolkit/components/extensions/schemas/notifications.json new file mode 100644 index 0000000000..79a5497997 --- /dev/null +++ b/toolkit/components/extensions/schemas/notifications.json @@ -0,0 +1,416 @@ +[ + { + "namespace": "notifications", + "permissions": ["notifications"], + "types": [ + { + "id": "TemplateType", + "type": "string", + "enum": ["basic", "image", "list", "progress"] + }, + { + "id": "PermissionLevel", + "type": "string", + "enum": ["granted", "denied"] + }, + { + "id": "NotificationItem", + "type": "object", + "properties": { + "title": { + "description": "Title of one item of a list notification.", + "type": "string" + }, + "message": { + "description": "Additional details about this item.", + "type": "string" + } + } + }, + { + "id": "CreateNotificationOptions", + "type": "object", + "properties": { + "type": { + "description": "Which type of notification to display.", + "$ref": "TemplateType" + }, + "iconUrl": { + "optional": true, + "description": "A URL to the sender's avatar, app icon, or a thumbnail for image notifications.", + "type": "string" + }, + "appIconMaskUrl": { + "optional": true, + "description": "A URL to the app icon mask.", + "type": "string" + }, + "title": { + "description": "Title of the notification (e.g. sender name for email).", + "type": "string" + }, + "message": { + "description": "Main notification content.", + "type": "string" + }, + "contextMessage": { + "optional": true, + "description": "Alternate notification content with a lower-weight font.", + "type": "string" + }, + "priority": { + "optional": true, + "description": "Priority ranges from -2 to 2. -2 is lowest priority. 2 is highest. Zero is default.", + "type": "integer", + "minimum": -2, + "maximum": 2 + }, + "eventTime": { + "optional": true, + "description": "A timestamp associated with the notification, in milliseconds past the epoch.", + "type": "number" + }, + "buttons": { + "unsupported": true, + "optional": true, + "description": "Text and icons for up to two notification action buttons.", + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "iconUrl": { + "optional": true, + "type": "string" + } + } + } + }, + "imageUrl": { + "optional": true, + "description": "A URL to the image thumbnail for image-type notifications.", + "type": "string" + }, + "items": { + "optional": true, + "description": "Items for multi-item notifications.", + "type": "array", + "items": { "$ref": "NotificationItem" } + }, + "progress": { + "optional": true, + "description": "Current progress ranges from 0 to 100.", + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "isClickable": { + "optional": true, + "description": "Whether to show UI indicating that the app will visibly respond to clicks on the body of a notification.", + "type": "boolean" + } + } + }, + { + "id": "UpdateNotificationOptions", + "type": "object", + "properties": { + "type": { + "optional": true, + "description": "Which type of notification to display.", + "$ref": "TemplateType" + }, + "iconUrl": { + "optional": true, + "description": "A URL to the sender's avatar, app icon, or a thumbnail for image notifications.", + "type": "string" + }, + "appIconMaskUrl": { + "optional": true, + "description": "A URL to the app icon mask.", + "type": "string" + }, + "title": { + "optional": true, + "description": "Title of the notification (e.g. sender name for email).", + "type": "string" + }, + "message": { + "optional": true, + "description": "Main notification content.", + "type": "string" + }, + "contextMessage": { + "optional": true, + "description": "Alternate notification content with a lower-weight font.", + "type": "string" + }, + "priority": { + "optional": true, + "description": "Priority ranges from -2 to 2. -2 is lowest priority. 2 is highest. Zero is default.", + "type": "integer", + "minimum": -2, + "maximum": 2 + }, + "eventTime": { + "optional": true, + "description": "A timestamp associated with the notification, in milliseconds past the epoch.", + "type": "number" + }, + "buttons": { + "unsupported": true, + "optional": true, + "description": "Text and icons for up to two notification action buttons.", + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "iconUrl": { + "optional": true, + "type": "string" + } + } + } + }, + "imageUrl": { + "optional": true, + "description": "A URL to the image thumbnail for image-type notifications.", + "type": "string" + }, + "items": { + "optional": true, + "description": "Items for multi-item notifications.", + "type": "array", + "items": { "$ref": "NotificationItem" } + }, + "progress": { + "optional": true, + "description": "Current progress ranges from 0 to 100.", + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "isClickable": { + "optional": true, + "description": "Whether to show UI indicating that the app will visibly respond to clicks on the body of a notification.", + "type": "boolean" + } + } + } + ], + "functions": [ + { + "name": "create", + "type": "function", + "description": "Creates and displays a notification.", + "async": "callback", + "parameters": [ + { + "optional": true, + "type": "string", + "name": "notificationId", + "description": "Identifier of the notification. If it is empty, this method generates an id. If it matches an existing notification, this method first clears that notification before proceeding with the create operation." + }, + { + "$ref": "CreateNotificationOptions", + "name": "options", + "description": "Contents of the notification." + }, + { + "optional": true, + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "notificationId", + "type": "string", + "description": "The notification id (either supplied or generated) that represents the created notification." + } + ] + } + ] + }, + { + "name": "update", + "unsupported": true, + "type": "function", + "description": "Updates an existing notification.", + "async": "callback", + "parameters": [ + { + "type": "string", + "name": "notificationId", + "description": "The id of the notification to be updated." + }, + { + "$ref": "UpdateNotificationOptions", + "name": "options", + "description": "Contents of the notification to update to." + }, + { + "optional": true, + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "wasUpdated", + "type": "boolean", + "description": "Indicates whether a matching notification existed." + } + ] + } + ] + }, + { + "name": "clear", + "type": "function", + "description": "Clears an existing notification.", + "async": "callback", + "parameters": [ + { + "type": "string", + "name": "notificationId", + "description": "The id of the notification to be updated." + }, + { + "optional": true, + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "wasCleared", + "type": "boolean", + "description": "Indicates whether a matching notification existed." + } + ] + } + ] + }, + { + "name": "getAll", + "type": "function", + "description": "Retrieves all the notifications.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "notifications", + "type": "object", + "additionalProperties": { "$ref": "CreateNotificationOptions" }, + "description": "The set of notifications currently in the system." + } + ] + } + ] + }, + { + "name": "getPermissionLevel", + "unsupported": true, + "type": "function", + "description": "Retrieves whether the user has enabled notifications from this app or extension.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "level", + "$ref": "PermissionLevel", + "description": "The current permission level." + } + ] + } + ] + } + ], + "events": [ + { + "name": "onClosed", + "type": "function", + "description": "Fired when the notification closed, either by the system or by user action.", + "parameters": [ + { + "type": "string", + "name": "notificationId", + "description": "The notificationId of the closed notification." + }, + { + "type": "boolean", + "name": "byUser", + "description": "True if the notification was closed by the user." + } + ] + }, + { + "name": "onClicked", + "type": "function", + "description": "Fired when the user clicked in a non-button area of the notification.", + "parameters": [ + { + "type": "string", + "name": "notificationId", + "description": "The notificationId of the clicked notification." + } + ] + }, + { + "name": "onButtonClicked", + "type": "function", + "description": "Fired when the user pressed a button in the notification.", + "parameters": [ + { + "type": "string", + "name": "notificationId", + "description": "The notificationId of the clicked notification." + }, + { + "type": "number", + "name": "buttonIndex", + "description": "The index of the button clicked by the user." + } + ] + }, + { + "name": "onPermissionLevelChanged", + "unsupported": true, + "type": "function", + "description": "Fired when the user changes the permission level.", + "parameters": [ + { + "$ref": "PermissionLevel", + "name": "level", + "description": "The new permission level." + } + ] + }, + { + "name": "onShowSettings", + "unsupported": true, + "type": "function", + "description": "Fired when the user clicked on a link for the app's notification settings.", + "parameters": [] + }, + { + "name": "onShown", + "type": "function", + "description": "Fired when the notification is shown.", + "parameters": [ + { + "type": "string", + "name": "notificationId", + "description": "The notificationId of the shown notification." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/page_action.json b/toolkit/components/extensions/schemas/page_action.json new file mode 100644 index 0000000000..e064f87d44 --- /dev/null +++ b/toolkit/components/extensions/schemas/page_action.json @@ -0,0 +1,329 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "WebExtensionManifest", + "properties": { + "page_action": { + "type": "object", + "additionalProperties": { "$ref": "UnrecognizedProperty" }, + "properties": { + "default_title": { + "type": "string", + "optional": true, + "preprocess": "localize" + }, + "default_icon": { + "$ref": "IconPath", + "optional": true + }, + "default_popup": { + "type": "string", + "format": "relativeUrl", + "optional": true, + "preprocess": "localize" + }, + "browser_style": { + "type": "boolean", + "optional": true, + "description": "Deprecated in Manifest V3." + }, + "show_matches": { + "type": "array", + "optional": true, + "minItems": 1, + "items": { "$ref": "MatchPattern" } + }, + "hide_matches": { + "type": "array", + "optional": true, + "minItems": 1, + "items": { "$ref": "MatchPatternRestricted" } + }, + "pinned": { + "type": "boolean", + "optional": true, + "default": true + } + }, + "optional": true + } + } + } + ] + }, + { + "namespace": "pageAction", + "description": "Use the browser.pageAction API to put icons inside the address bar. Page actions represent actions that can be taken on the current page, but that aren't applicable to all pages.", + "permissions": ["manifest:page_action"], + "types": [ + { + "id": "ImageDataType", + "type": "object", + "isInstanceOf": "ImageData", + "additionalProperties": { "type": "any" }, + "postprocess": "convertImageDataToURL", + "description": "Pixel data for an image. Must be an ImageData object (for example, from a canvas element)." + }, + { + "id": "OnClickData", + "type": "object", + "description": "Information sent when a page action is clicked.", + "properties": { + "modifiers": { + "type": "array", + "items": { + "type": "string", + "enum": ["Shift", "Alt", "Command", "Ctrl", "MacCtrl"] + }, + "description": "An array of keyboard modifiers that were held while the menu item was clicked." + }, + "button": { + "type": "integer", + "optional": true, + "description": "An integer value of button by which menu item was clicked." + } + } + } + ], + "functions": [ + { + "name": "show", + "type": "function", + "async": "callback", + "description": "Shows the page action. The page action is shown whenever the tab is selected.", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "description": "The id of the tab for which you want to modify the page action." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "hide", + "type": "function", + "async": "callback", + "description": "Hides the page action.", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "description": "The id of the tab for which you want to modify the page action." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "isShown", + "type": "function", + "description": "Checks whether the page action is shown.", + "async": true, + "parameters": [ + { + "name": "details", + "type": "object", + "properties": { + "tabId": { + "type": "integer", + "description": "Specify the tab to get the shownness from." + } + } + } + ] + }, + { + "name": "setTitle", + "type": "function", + "description": "Sets the title of the page action. This is displayed in a tooltip over the page action.", + "parameters": [ + { + "name": "details", + "type": "object", + "properties": { + "tabId": { + "type": "integer", + "minimum": 0, + "description": "The id of the tab for which you want to modify the page action." + }, + "title": { + "choices": [{ "type": "string" }, { "type": "null" }], + "description": "The tooltip string." + } + } + } + ] + }, + { + "name": "getTitle", + "type": "function", + "description": "Gets the title of the page action.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "properties": { + "tabId": { + "type": "integer", + "description": "Specify the tab to get the title from." + } + } + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "result", + "type": "string" + } + ] + } + ] + }, + { + "name": "setIcon", + "type": "function", + "description": "Sets the icon for the page action. The icon can be specified either as the path to an image file or as the pixel data from a canvas element, or as dictionary of either one of those. Either the path or the imageData property must be specified.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "properties": { + "tabId": { + "type": "integer", + "minimum": 0, + "description": "The id of the tab for which you want to modify the page action." + }, + "imageData": { + "choices": [ + { "$ref": "ImageDataType" }, + { + "type": "object", + "patternProperties": { + "^[1-9]\\d*$": { "$ref": "ImageDataType" } + } + } + ], + "optional": true, + "description": "Either an ImageData object or a dictionary {size -> ImageData} representing icon to be set. If the icon is specified as a dictionary, the actual image to be used is chosen depending on screen's pixel density. If the number of image pixels that fit into one screen space unit equals scale, then image with size scale * 19 will be selected. Initially only scales 1 and 2 will be supported. At least one image must be specified. Note that 'details.imageData = foo' is equivalent to 'details.imageData = {'19': foo}'" + }, + "path": { + "choices": [ + { "type": "string" }, + { + "type": "object", + "patternProperties": { + "^[1-9]\\d*$": { "type": "string" } + } + } + ], + "optional": true, + "description": "Either a relative image path or a dictionary {size -> relative image path} pointing to icon to be set. If the icon is specified as a dictionary, the actual image to be used is chosen depending on screen's pixel density. If the number of image pixels that fit into one screen space unit equals scale, then image with size scale * 19 will be selected. Initially only scales 1 and 2 will be supported. At least one image must be specified. Note that 'details.path = foo' is equivalent to 'details.imageData = {'19': foo}'" + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "setPopup", + "type": "function", + "async": true, + "description": "Sets the html document to be opened as a popup when the user clicks on the page action's icon.", + "parameters": [ + { + "name": "details", + "type": "object", + "properties": { + "tabId": { + "type": "integer", + "minimum": 0, + "description": "The id of the tab for which you want to modify the page action." + }, + "popup": { + "choices": [{ "type": "string" }, { "type": "null" }], + "description": "The html file to show in a popup. If set to the empty string (''), no popup is shown." + } + } + } + ] + }, + { + "name": "getPopup", + "type": "function", + "description": "Gets the html document set as the popup for this page action.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "properties": { + "tabId": { + "type": "integer", + "description": "Specify the tab to get the popup from." + } + } + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "result", + "type": "string" + } + ] + } + ] + }, + { + "name": "openPopup", + "type": "function", + "requireUserInput": true, + "description": "Opens the extension page action in the active window.", + "async": true, + "parameters": [] + } + ], + "events": [ + { + "name": "onClicked", + "type": "function", + "description": "Fired when a page action icon is clicked. This event will not fire if the page action has a popup.", + "parameters": [ + { + "name": "tab", + "$ref": "tabs.Tab" + }, + { + "name": "info", + "$ref": "OnClickData", + "optional": true + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/permissions.json b/toolkit/components/extensions/schemas/permissions.json new file mode 100644 index 0000000000..eb66fa7c64 --- /dev/null +++ b/toolkit/components/extensions/schemas/permissions.json @@ -0,0 +1,150 @@ +[ + { + "namespace": "permissions", + "types": [ + { + "id": "Permissions", + "type": "object", + "properties": { + "permissions": { + "type": "array", + "items": { "$ref": "manifest.OptionalPermission" }, + "optional": true, + "default": [] + }, + "origins": { + "type": "array", + "items": { "$ref": "manifest.MatchPattern" }, + "optional": true, + "default": [] + } + } + }, + { + "id": "AnyPermissions", + "type": "object", + "properties": { + "permissions": { + "type": "array", + "items": { "$ref": "manifest.Permission" }, + "optional": true, + "default": [] + }, + "origins": { + "type": "array", + "items": { "$ref": "manifest.MatchPattern" }, + "optional": true, + "default": [] + } + } + } + ], + "functions": [ + { + "name": "getAll", + "type": "function", + "async": "callback", + "description": "Get a list of all the extension's permissions.", + "parameters": [ + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "permissions", + "$ref": "AnyPermissions" + } + ] + } + ] + }, + { + "name": "contains", + "type": "function", + "async": "callback", + "description": "Check if the extension has the given permissions.", + "parameters": [ + { + "name": "permissions", + "$ref": "AnyPermissions" + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "result", + "type": "boolean" + } + ] + } + ] + }, + { + "name": "request", + "type": "function", + "allowedContexts": ["content"], + "async": "callback", + "requireUserInput": true, + "description": "Request the given permissions.", + "parameters": [ + { + "name": "permissions", + "$ref": "Permissions" + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "granted", + "type": "boolean" + } + ] + } + ] + }, + { + "name": "remove", + "type": "function", + "async": "callback", + "description": "Relinquish the given permissions.", + "parameters": [ + { + "name": "permissions", + "$ref": "Permissions" + }, + { + "name": "callback", + "type": "function", + "parameters": [] + } + ] + } + ], + "events": [ + { + "name": "onAdded", + "type": "function", + "description": "Fired when the extension acquires new permissions.", + "parameters": [ + { + "name": "permissions", + "$ref": "Permissions" + } + ] + }, + { + "name": "onRemoved", + "type": "function", + "description": "Fired when permissions are removed from the extension.", + "parameters": [ + { + "name": "permissions", + "$ref": "Permissions" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/privacy.json b/toolkit/components/extensions/schemas/privacy.json new file mode 100644 index 0000000000..54c78a4c83 --- /dev/null +++ b/toolkit/components/extensions/schemas/privacy.json @@ -0,0 +1,177 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermission", + "choices": [ + { + "type": "string", + "enum": ["privacy"] + } + ] + } + ] + }, + { + "namespace": "privacy", + "permissions": ["privacy"] + }, + { + "namespace": "privacy.network", + "description": "Use the browser.privacy API to control usage of the features in the browser that can affect a user's privacy.", + "permissions": ["privacy"], + "types": [ + { + "id": "IPHandlingPolicy", + "type": "string", + "enum": [ + "default", + "default_public_and_private_interfaces", + "default_public_interface_only", + "disable_non_proxied_udp", + "proxy_only" + ], + "description": "The IP handling policy of WebRTC." + }, + { + "id": "tlsVersionRestrictionConfig", + "type": "object", + "description": "An object which describes TLS minimum and maximum versions.", + "properties": { + "minimum": { + "type": "string", + "enum": ["TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3", "unknown"], + "optional": true, + "description": "The minimum TLS version supported." + }, + "maximum": { + "type": "string", + "enum": ["TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3", "unknown"], + "optional": true, + "description": "The maximum TLS version supported." + } + } + }, + { + "id": "HTTPSOnlyModeOption", + "type": "string", + "enum": ["always", "private_browsing", "never"], + "description": "The mode for https-only mode." + } + ], + "properties": { + "networkPredictionEnabled": { + "$ref": "types.Setting", + "description": "If enabled, the browser attempts to speed up your web browsing experience by pre-resolving DNS entries, prerendering sites (<link rel='prefetch' ...>), and preemptively opening TCP and SSL connections to servers. This preference's value is a boolean, defaulting to true." + }, + "peerConnectionEnabled": { + "$ref": "types.Setting", + "description": "Allow users to enable and disable RTCPeerConnections (aka WebRTC)." + }, + "webRTCIPHandlingPolicy": { + "$ref": "types.Setting", + "description": "Allow users to specify the media performance/privacy tradeoffs which impacts how WebRTC traffic will be routed and how much local address information is exposed. This preference's value is of type IPHandlingPolicy, defaulting to default." + }, + "tlsVersionRestriction": { + "$ref": "types.Setting", + "description": "This property controls the minimum and maximum TLS versions. This setting's value is an object of $(ref:tlsVersionRestrictionConfig)." + }, + "httpsOnlyMode": { + "$ref": "types.Setting", + "description": "Allow users to query the mode for 'HTTPS-Only Mode'. This setting's value is of type HTTPSOnlyModeOption, defaulting to never." + }, + "globalPrivacyControl": { + "$ref": "types.Setting", + "description": "Allow users to query the status of 'Global Privacy Control'. This setting's value is of type boolean, defaulting to false." + } + } + }, + { + "namespace": "privacy.services", + "description": "Use the browser.privacy API to control usage of the features in the browser that can affect a user's privacy.", + "permissions": ["privacy"], + "properties": { + "passwordSavingEnabled": { + "$ref": "types.Setting", + "description": "If enabled, the password manager will ask if you want to save passwords. This preference's value is a boolean, defaulting to true." + } + } + }, + { + "namespace": "privacy.websites", + "description": "Use the browser.privacy API to control usage of the features in the browser that can affect a user's privacy.", + "permissions": ["privacy"], + "types": [ + { + "id": "TrackingProtectionModeOption", + "type": "string", + "enum": ["always", "never", "private_browsing"], + "description": "The mode for tracking protection." + }, + { + "id": "CookieConfig", + "type": "object", + "description": "The settings for cookies.", + "properties": { + "behavior": { + "type": "string", + "optional": true, + "enum": [ + "allow_all", + "reject_all", + "reject_third_party", + "allow_visited", + "reject_trackers", + "reject_trackers_and_partition_foreign" + ], + "description": "The type of cookies to allow." + }, + "nonPersistentCookies": { + "type": "boolean", + "optional": true, + "default": false, + "description": "Whether to create all cookies as nonPersistent (i.e., session) cookies.", + "deprecated": "This property has no effect anymore and its value is always false." + } + } + } + ], + "properties": { + "thirdPartyCookiesAllowed": { + "$ref": "types.Setting", + "description": "If disabled, the browser blocks third-party sites from setting cookies. The value of this preference is of type boolean, and the default value is true.", + "unsupported": true + }, + "hyperlinkAuditingEnabled": { + "$ref": "types.Setting", + "description": "If enabled, the browser sends auditing pings when requested by a website (<a ping>). The value of this preference is of type boolean, and the default value is true." + }, + "referrersEnabled": { + "$ref": "types.Setting", + "description": "If enabled, the browser sends referer headers with your requests. Yes, the name of this preference doesn't match the misspelled header. No, we're not going to change it. The value of this preference is of type boolean, and the default value is true." + }, + "resistFingerprinting": { + "$ref": "types.Setting", + "description": "If enabled, the browser attempts to appear similar to other users by reporting generic information to websites. This can prevent websites from uniquely identifying users. Examples of data that is spoofed include number of CPU cores, precision of JavaScript timers, the local timezone, and disabling features such as GamePad support, and the WebSpeech and Navigator APIs. The value of this preference is of type boolean, and the default value is false." + }, + "firstPartyIsolate": { + "$ref": "types.Setting", + "description": "If enabled, the browser will associate all data (including cookies, HSTS data, cached images, and more) for any third party domains with the domain in the address bar. This prevents third party trackers from using directly stored information to identify you across different websites, but may break websites where you login with a third party account (such as a Facebook or Google login.) The value of this preference is of type boolean, and the default value is false." + }, + "protectedContentEnabled": { + "$ref": "types.Setting", + "description": "Available on Windows and ChromeOS only: If enabled, the browser provides a unique ID to plugins in order to run protected content. The value of this preference is of type boolean, and the default value is true.", + "unsupported": true + }, + "trackingProtectionMode": { + "$ref": "types.Setting", + "description": "Allow users to specify the mode for tracking protection. This setting's value is of type TrackingProtectionModeOption, defaulting to private_browsing_only." + }, + "cookieConfig": { + "$ref": "types.Setting", + "description": "Allow users to specify the default settings for allowing cookies, as well as whether all cookies should be created as non-persistent cookies. This setting's value is of type CookieConfig." + } + } + } +] diff --git a/toolkit/components/extensions/schemas/proxy.json b/toolkit/components/extensions/schemas/proxy.json new file mode 100644 index 0000000000..78617c2137 --- /dev/null +++ b/toolkit/components/extensions/schemas/proxy.json @@ -0,0 +1,210 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermission", + "choices": [ + { + "type": "string", + "enum": ["proxy"] + } + ] + } + ] + }, + { + "namespace": "proxy", + "description": "Provides access to global proxy settings for Firefox and proxy event listeners to handle dynamic proxy implementations.", + "permissions": ["proxy"], + "types": [ + { + "id": "ProxyConfig", + "type": "object", + "description": "An object which describes proxy settings.", + "properties": { + "proxyType": { + "type": "string", + "optional": true, + "enum": ["none", "autoDetect", "system", "manual", "autoConfig"], + "description": "The type of proxy to use." + }, + "http": { + "type": "string", + "optional": true, + "description": "The address of the http proxy, can include a port." + }, + "httpProxyAll": { + "type": "boolean", + "optional": true, + "description": "Use the http proxy server for all protocols." + }, + "ftp": { + "type": "string", + "optional": true, + "deprecated": true, + "description": "The address of the ftp proxy, can include a port. Deprecated since Firefox 88." + }, + "ssl": { + "type": "string", + "optional": true, + "description": "The address of the ssl proxy, can include a port." + }, + "socks": { + "type": "string", + "optional": true, + "description": "The address of the socks proxy, can include a port." + }, + "socksVersion": { + "type": "integer", + "optional": true, + "description": "The version of the socks proxy.", + "minimum": 4, + "maximum": 5 + }, + "passthrough": { + "type": "string", + "optional": true, + "description": "A list of hosts which should not be proxied." + }, + "autoConfigUrl": { + "type": "string", + "optional": true, + "description": "A URL to use to configure the proxy." + }, + "autoLogin": { + "type": "boolean", + "optional": true, + "description": "Do not prompt for authentication if password is saved." + }, + "proxyDNS": { + "type": "boolean", + "optional": true, + "description": "Proxy DNS when using SOCKS v5." + }, + "respectBeConservative": { + "type": "boolean", + "optional": true, + "default": true, + "description": " If true (the default value), do not use newer TLS protocol features that might have interoperability problems on the Internet. This is intended only for use with critical infrastructure like the updates, and is only available to privileged addons." + } + } + } + ], + "properties": { + "settings": { + "$ref": "types.Setting", + "description": "Configures proxy settings. This setting's value is an object of type ProxyConfig." + } + }, + "events": [ + { + "name": "onRequest", + "type": "function", + "description": "Fired when proxy data is needed for a request.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": { + "type": "string", + "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request." + }, + "url": { "type": "string" }, + "method": { + "type": "string", + "description": "Standard HTTP method." + }, + "frameId": { + "type": "integer", + "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (type is main_frame or sub_frame), frameId indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists." + }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "True for private browsing requests." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "originUrl": { + "type": "string", + "optional": true, + "description": "URL of the resource that triggered this request." + }, + "documentUrl": { + "type": "string", + "optional": true, + "description": "URL of the page into which the requested resource will be loaded." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab." + }, + "type": { + "$ref": "webRequest.ResourceType", + "description": "How the requested resource will be used." + }, + "timeStamp": { + "type": "number", + "description": "The time when this signal is triggered, in milliseconds since the epoch." + }, + "fromCache": { + "type": "boolean", + "description": "Indicates if this response was fetched from disk cache." + }, + "requestHeaders": { + "$ref": "webRequest.HttpHeaders", + "optional": true, + "description": "The HTTP request headers that are going to be sent out with this request." + }, + "urlClassification": { + "$ref": "webRequest.UrlClassification", + "description": "Url classification if the request has been classified." + }, + "thirdParty": { + "type": "boolean", + "description": "Indicates if this request and its content window hierarchy is third party." + } + } + } + ], + "extraParameters": [ + { + "$ref": "webRequest.RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "type": "string", + "enum": ["requestHeaders"] + } + } + ] + }, + { + "name": "onError", + "type": "function", + "description": "Notifies about errors caused by the invalid use of the proxy API.", + "parameters": [ + { + "name": "error", + "type": "object" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/runtime.json b/toolkit/components/extensions/schemas/runtime.json new file mode 100644 index 0000000000..75ff341393 --- /dev/null +++ b/toolkit/components/extensions/schemas/runtime.json @@ -0,0 +1,721 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermission", + "choices": [ + { + "type": "string", + "enum": ["nativeMessaging"] + } + ] + } + ] + }, + { + "namespace": "runtime", + "allowedContexts": ["content", "devtools"], + "description": "Use the browser.runtime API to retrieve the background page, return details about the manifest, and listen for and respond to events in the app or extension lifecycle. You can also use this API to convert the relative path of URLs to fully-qualified URLs.", + "types": [ + { + "id": "Port", + "type": "object", + "allowedContexts": ["content", "devtools"], + "description": "An object which allows two way communication with other pages.", + "properties": { + "name": { "type": "string" }, + "disconnect": { "type": "function" }, + "onDisconnect": { "$ref": "events.Event" }, + "onMessage": { "$ref": "events.Event" }, + "postMessage": { "type": "function" }, + "sender": { + "$ref": "MessageSender", + "optional": true, + "description": "This property will only be present on ports passed to onConnect/onConnectExternal listeners." + } + }, + "additionalProperties": { "type": "any" } + }, + { + "id": "MessageSender", + "type": "object", + "allowedContexts": ["content", "devtools"], + "description": "An object containing information about the script context that sent a message or request.", + "properties": { + "tab": { + "$ref": "tabs.Tab", + "optional": true, + "description": "The $(ref:tabs.Tab) which opened the connection, if any. This property will only be present when the connection was opened from a tab (including content scripts), and only if the receiver is an extension, not an app." + }, + "frameId": { + "type": "integer", + "optional": true, + "description": "The $(topic:frame_ids)[frame] that opened the connection. 0 for top-level frames, positive for child frames. This will only be set when tab is set." + }, + "id": { + "type": "string", + "optional": true, + "description": "The ID of the extension or app that opened the connection, if any." + }, + "url": { + "type": "string", + "optional": true, + "description": "The URL of the page or frame that opened the connection. If the sender is in an iframe, it will be iframe's URL not the URL of the page which hosts it." + }, + "tlsChannelId": { + "unsupported": true, + "type": "string", + "optional": true, + "description": "The TLS channel ID of the page or frame that opened the connection, if requested by the extension or app, and if available." + } + } + }, + { + "id": "PlatformOs", + "type": "string", + "allowedContexts": ["content", "devtools"], + "description": "The operating system the browser is running on.", + "enum": ["mac", "win", "android", "cros", "linux", "openbsd"] + }, + { + "id": "PlatformArch", + "type": "string", + "enum": [ + "aarch64", + "arm", + "ppc64", + "s390x", + "sparc64", + "x86-32", + "x86-64", + "noarch" + ], + "allowedContexts": ["content", "devtools"], + "description": "The machine's processor architecture." + }, + { + "id": "PlatformInfo", + "type": "object", + "allowedContexts": ["content", "devtools"], + "description": "An object containing information about the current platform.", + "properties": { + "os": { + "$ref": "PlatformOs", + "description": "The operating system the browser is running on." + }, + "arch": { + "$ref": "PlatformArch", + "description": "The machine's processor architecture." + }, + "nacl_arch": { + "unsupported": true, + "description": "The native client architecture. This may be different from arch on some platforms.", + "$ref": "PlatformNaclArch" + } + } + }, + { + "id": "BrowserInfo", + "type": "object", + "description": "An object containing information about the current browser.", + "properties": { + "name": { + "type": "string", + "description": "The name of the browser, for example 'Firefox'." + }, + "vendor": { + "type": "string", + "description": "The name of the browser vendor, for example 'Mozilla'." + }, + "version": { + "type": "string", + "description": "The browser's version, for example '42.0.0' or '0.8.1pre'." + }, + "buildID": { + "type": "string", + "description": "The browser's build ID/date, for example '20160101'." + } + } + }, + { + "id": "RequestUpdateCheckStatus", + "type": "string", + "enum": ["throttled", "no_update", "update_available"], + "allowedContexts": ["content", "devtools"], + "description": "Result of the update check." + }, + { + "id": "OnInstalledReason", + "type": "string", + "enum": ["install", "update", "browser_update"], + "allowedContexts": ["content", "devtools"], + "description": "The reason that this event is being dispatched." + }, + { + "id": "OnRestartRequiredReason", + "type": "string", + "allowedContexts": ["content", "devtools"], + "description": "The reason that the event is being dispatched. 'app_update' is used when the restart is needed because the application is updated to a newer version. 'os_update' is used when the restart is needed because the browser/OS is updated to a newer version. 'periodic' is used when the system runs for more than the permitted uptime set in the enterprise policy.", + "enum": ["app_update", "os_update", "periodic"] + }, + { + "id": "OnPerformanceWarningCategory", + "type": "string", + "enum": ["content_script"], + "description": "The performance warning event category, e.g. 'content_script'." + }, + { + "id": "OnPerformanceWarningSeverity", + "type": "string", + "enum": ["low", "medium", "high"], + "description": "The performance warning event severity. Will be 'high' for serious and user-visible issues." + } + ], + "properties": { + "lastError": { + "type": "object", + "optional": true, + "allowedContexts": ["content", "devtools"], + "description": "This will be defined during an API method callback if there was an error", + "properties": { + "message": { + "optional": true, + "type": "string", + "description": "Details about the error which occurred." + } + }, + "additionalProperties": { + "type": "any" + } + }, + "id": { + "type": "string", + "allowedContexts": ["content", "devtools"], + "description": "The ID of the extension/app." + } + }, + "functions": [ + { + "name": "getBackgroundPage", + "type": "function", + "description": "Retrieves the JavaScript 'window' object for the background page running inside the current extension/app. If the background page is an event page, the system will ensure it is loaded before calling the callback. If there is no background page, an error is set.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "backgroundPage", + "optional": true, + "type": "object", + "isInstanceOf": "Window", + "additionalProperties": { "type": "any" }, + "description": "The JavaScript 'window' object for the background page." + } + ] + } + ] + }, + { + "name": "openOptionsPage", + "type": "function", + "description": "

Open your Extension's options page, if possible.

The precise behavior may depend on your manifest's $(topic:optionsV2)[options_ui] or $(topic:options)[options_page] key, or what the browser happens to support at the time.

If your Extension does not declare an options page, or the browser failed to create one for some other reason, the callback will set $(ref:lastError).

", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [], + "optional": true + } + ] + }, + { + "name": "getManifest", + "allowedContexts": ["content", "devtools"], + "description": "Returns details about the app or extension from the manifest. The object returned is a serialization of the full $(topic:manifest)[manifest file].", + "type": "function", + "parameters": [], + "returns": { + "type": "object", + "properties": {}, + "additionalProperties": { "type": "any" }, + "description": "The manifest details." + } + }, + { + "name": "getURL", + "type": "function", + "allowedContexts": ["content", "devtools"], + "description": "Converts a relative path within an app/extension install directory to a fully-qualified URL.", + "parameters": [ + { + "type": "string", + "name": "path", + "description": "A path to a resource within an app/extension expressed relative to its install directory." + } + ], + "returns": { + "type": "string", + "description": "The fully-qualified URL to the resource." + } + }, + { + "name": "getFrameId", + "type": "function", + "allowedContexts": ["content", "devtools"], + "description": "Get the frameId of any window global or frame element.", + "parameters": [ + { + "type": "any", + "name": "target", + "description": "A WindowProxy or a Browsing Context container element (IFrame, Frame, Embed, Object) for the target frame." + } + ], + "allowCrossOriginArguments": true, + "returns": { + "type": "number", + "description": "The frameId of the target frame, or -1 if it doesn't exist." + } + }, + { + "name": "setUninstallURL", + "type": "function", + "description": "Sets the URL to be visited upon uninstallation. This may be used to clean up server-side data, do analytics, and implement surveys. Maximum 1023 characters.", + "async": "callback", + "parameters": [ + { + "type": "string", + "name": "url", + "optional": true, + "maxLength": 1023, + "description": "URL to be opened after the extension is uninstalled. This URL must have an http: or https: scheme. Set an empty string to not open a new tab upon uninstallation." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "description": "Called when the uninstall URL is set. If the given URL is invalid, $(ref:runtime.lastError) will be set.", + "parameters": [] + } + ] + }, + { + "name": "reload", + "description": "Reloads the app or extension.", + "type": "function", + "parameters": [] + }, + { + "name": "requestUpdateCheck", + "unsupported": true, + "type": "function", + "description": "Requests an update check for this app/extension.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "status", + "$ref": "RequestUpdateCheckStatus", + "description": "Result of the update check." + }, + { + "name": "details", + "type": "object", + "optional": true, + "properties": { + "version": { + "type": "string", + "description": "The version of the available update." + } + }, + "description": "If an update is available, this contains more information about the available update." + } + ] + } + ] + }, + { + "name": "restart", + "unsupported": true, + "description": "Restart the device when the app runs in kiosk mode. Otherwise, it's no-op.", + "type": "function", + "parameters": [] + }, + { + "name": "connect", + "type": "function", + "allowedContexts": ["content", "devtools"], + "description": "Attempts to connect to connect listeners within an extension/app (such as the background page), or other extensions/apps. This is useful for content scripts connecting to their extension processes, inter-app/extension communication, and $(topic:manifest/externally_connectable)[web messaging]. Note that this does not connect to any listeners in a content script. Extensions may connect to content scripts embedded in tabs via $(ref:tabs.connect).", + "parameters": [ + { + "type": "string", + "name": "extensionId", + "optional": true, + "description": "The ID of the extension or app to connect to. If omitted, a connection will be attempted with your own extension. Required if sending messages from a web page for $(topic:manifest/externally_connectable)[web messaging]." + }, + { + "type": "object", + "name": "connectInfo", + "properties": { + "name": { + "type": "string", + "optional": true, + "description": "Will be passed into onConnect for processes that are listening for the connection event." + }, + "includeTlsChannelId": { + "type": "boolean", + "optional": true, + "description": "Whether the TLS channel ID will be passed into onConnectExternal for processes that are listening for the connection event." + } + }, + "optional": true + } + ], + "returns": { + "$ref": "Port", + "description": "Port through which messages can be sent and received. The port's $(ref:runtime.Port onDisconnect) event is fired if the extension/app does not exist. " + } + }, + { + "name": "connectNative", + "type": "function", + "description": "Connects to a native application in the host machine.", + "allowedContexts": ["content"], + "permissions": ["nativeMessaging"], + "parameters": [ + { + "type": "string", + "pattern": "^\\w+(\\.\\w+)*$", + "name": "application", + "description": "The name of the registered application to connect to." + } + ], + "returns": { + "$ref": "Port", + "description": "Port through which messages can be sent and received with the application" + } + }, + { + "name": "sendMessage", + "type": "function", + "allowAmbiguousOptionalArguments": true, + "allowedContexts": ["content", "devtools"], + "description": "Sends a single message to event listeners within your extension/app or a different extension/app. Similar to $(ref:runtime.connect) but only sends a single message, with an optional response. If sending to your extension, the $(ref:runtime.onMessage) event will be fired in each page, or $(ref:runtime.onMessageExternal), if a different extension. Note that extensions cannot send messages to content scripts using this method. To send messages to content scripts, use $(ref:tabs.sendMessage).", + "async": "responseCallback", + "parameters": [ + { + "type": "string", + "name": "extensionId", + "optional": true, + "description": "The ID of the extension/app to send the message to. If omitted, the message will be sent to your own extension/app. Required if sending messages from a web page for $(topic:manifest/externally_connectable)[web messaging]." + }, + { "type": "any", "name": "message" }, + { + "type": "object", + "name": "options", + "properties": { + "includeTlsChannelId": { + "type": "boolean", + "optional": true, + "unsupported": true, + "description": "Whether the TLS channel ID will be passed into onMessageExternal for processes that are listening for the connection event." + } + }, + "optional": true + }, + { + "type": "function", + "name": "responseCallback", + "optional": true, + "parameters": [ + { + "name": "response", + "type": "any", + "description": "The JSON response object sent by the handler of the message. If an error occurs while connecting to the extension, the callback will be called with no arguments and $(ref:runtime.lastError) will be set to the error message." + } + ] + } + ] + }, + { + "name": "sendNativeMessage", + "type": "function", + "description": "Send a single message to a native application.", + "allowedContexts": ["content"], + "permissions": ["nativeMessaging"], + "async": "responseCallback", + "parameters": [ + { + "name": "application", + "description": "The name of the native messaging host.", + "type": "string", + "pattern": "^\\w+(\\.\\w+)*$" + }, + { + "name": "message", + "description": "The message that will be passed to the native messaging host.", + "type": "any" + }, + { + "type": "function", + "name": "responseCallback", + "optional": true, + "parameters": [ + { + "name": "response", + "type": "any", + "description": "The response message sent by the native messaging host. If an error occurs while connecting to the native messaging host, the callback will be called with no arguments and $(ref:runtime.lastError) will be set to the error message." + } + ] + } + ] + }, + { + "name": "getBrowserInfo", + "type": "function", + "description": "Returns information about the current browser.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "description": "Called with results", + "parameters": [ + { + "name": "browserInfo", + "$ref": "BrowserInfo" + } + ] + } + ] + }, + { + "name": "getPlatformInfo", + "type": "function", + "description": "Returns information about the current platform.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "description": "Called with results", + "parameters": [ + { + "name": "platformInfo", + "$ref": "PlatformInfo" + } + ] + } + ] + }, + { + "name": "getPackageDirectoryEntry", + "unsupported": true, + "type": "function", + "description": "Returns a DirectoryEntry for the package directory.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "directoryEntry", + "type": "object", + "additionalProperties": { "type": "any" }, + "isInstanceOf": "DirectoryEntry" + } + ] + } + ] + } + ], + "events": [ + { + "name": "onStartup", + "type": "function", + "description": "Fired when a profile that has this extension installed first starts up. This event is not fired for incognito profiles." + }, + { + "name": "onInstalled", + "type": "function", + "description": "Fired when the extension is first installed, when the extension is updated to a new version, and when the browser is updated to a new version.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "reason": { + "$ref": "OnInstalledReason", + "description": "The reason that this event is being dispatched." + }, + "previousVersion": { + "type": "string", + "optional": true, + "description": "Indicates the previous version of the extension, which has just been updated. This is present only if 'reason' is 'update'." + }, + "temporary": { + "type": "boolean", + "description": "Indicates whether the addon is installed as a temporary extension." + }, + "id": { + "type": "string", + "optional": true, + "unsupported": true, + "description": "Indicates the ID of the imported shared module extension which updated. This is present only if 'reason' is 'shared_module_update'." + } + } + } + ] + }, + { + "name": "onSuspend", + "type": "function", + "description": "Sent to the event page just before it is unloaded. This gives the extension opportunity to do some clean up. Note that since the page is unloading, any asynchronous operations started while handling this event are not guaranteed to complete. If more activity for the event page occurs before it gets unloaded the onSuspendCanceled event will be sent and the page won't be unloaded. " + }, + { + "name": "onSuspendCanceled", + "type": "function", + "description": "Sent after onSuspend to indicate that the app won't be unloaded after all." + }, + { + "name": "onUpdateAvailable", + "type": "function", + "description": "Fired when an update is available, but isn't installed immediately because the app is currently running. If you do nothing, the update will be installed the next time the background page gets unloaded, if you want it to be installed sooner you can explicitly call $(ref:runtime.reload). If your extension is using a persistent background page, the background page of course never gets unloaded, so unless you call $(ref:runtime.reload) manually in response to this event the update will not get installed until the next time the browser itself restarts. If no handlers are listening for this event, and your extension has a persistent background page, it behaves as if $(ref:runtime.reload) is called in response to this event.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "version": { + "type": "string", + "description": "The version number of the available update." + } + }, + "additionalProperties": { "type": "any" }, + "description": "The manifest details of the available update." + } + ] + }, + { + "name": "onBrowserUpdateAvailable", + "unsupported": true, + "type": "function", + "description": "Fired when an update for the browser is available, but isn't installed immediately because a browser restart is required.", + "deprecated": "Please use $(ref:runtime.onRestartRequired).", + "parameters": [] + }, + { + "name": "onConnect", + "type": "function", + "allowedContexts": ["content", "devtools"], + "description": "Fired when a connection is made from either an extension process or a content script.", + "parameters": [{ "$ref": "Port", "name": "port" }] + }, + { + "name": "onConnectExternal", + "type": "function", + "description": "Fired when a connection is made from another extension.", + "parameters": [{ "$ref": "Port", "name": "port" }] + }, + { + "name": "onMessage", + "type": "function", + "allowedContexts": ["content", "devtools"], + "description": "Fired when a message is sent from either an extension process or a content script.", + "parameters": [ + { + "name": "message", + "type": "any", + "optional": true, + "description": "The message sent by the calling script." + }, + { "name": "sender", "$ref": "MessageSender" }, + { + "name": "sendResponse", + "type": "function", + "description": "Function to call (at most once) when you have a response. The argument should be any JSON-ifiable object. If you have more than one onMessage listener in the same document, then only one may send a response. This function becomes invalid when the event listener returns, unless you return true from the event listener to indicate you wish to send a response asynchronously (this will keep the message channel open to the other end until sendResponse is called)." + } + ], + "returns": { + "type": "boolean", + "optional": true, + "description": "Return true from the event listener if you wish to call sendResponse after the event listener returns." + } + }, + { + "name": "onMessageExternal", + "type": "function", + "description": "Fired when a message is sent from another extension/app. Cannot be used in a content script.", + "parameters": [ + { + "name": "message", + "type": "any", + "optional": true, + "description": "The message sent by the calling script." + }, + { "name": "sender", "$ref": "MessageSender" }, + { + "name": "sendResponse", + "type": "function", + "description": "Function to call (at most once) when you have a response. The argument should be any JSON-ifiable object. If you have more than one onMessage listener in the same document, then only one may send a response. This function becomes invalid when the event listener returns, unless you return true from the event listener to indicate you wish to send a response asynchronously (this will keep the message channel open to the other end until sendResponse is called)." + } + ], + "returns": { + "type": "boolean", + "optional": true, + "description": "Return true from the event listener if you wish to call sendResponse after the event listener returns." + } + }, + { + "name": "onRestartRequired", + "unsupported": true, + "type": "function", + "description": "Fired when an app or the device that it runs on needs to be restarted. The app should close all its windows at its earliest convenient time to let the restart to happen. If the app does nothing, a restart will be enforced after a 24-hour grace period has passed. Currently, this event is only fired for Chrome OS kiosk apps.", + "parameters": [ + { + "$ref": "OnRestartRequiredReason", + "name": "reason", + "description": "The reason that the event is being dispatched." + } + ] + }, + { + "name": "onPerformanceWarning", + "type": "function", + "description": "Fired when a runtime performance issue is detected with the extension. Observe this event to be proactively notified of runtime performance problems with the extension.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "category": { + "$ref": "OnPerformanceWarningCategory", + "description": "The performance warning event category, e.g. 'content_script'." + }, + "severity": { + "$ref": "OnPerformanceWarningSeverity", + "description": "The performance warning event severity, e.g. 'high'." + }, + "tabId": { + "type": "integer", + "optional": true, + "description": "The $(ref:tabs.Tab) that the performance warning relates to, if any." + }, + "description": { + "type": "string", + "description": "An explanation of what the warning means, and hopefully how to address it." + } + } + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/scripting.json b/toolkit/components/extensions/schemas/scripting.json new file mode 100644 index 0000000000..75003620cc --- /dev/null +++ b/toolkit/components/extensions/schemas/scripting.json @@ -0,0 +1,361 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["scripting"] + } + ] + } + ] + }, + { + "namespace": "scripting", + "description": "Use the scripting API to execute script in different contexts.", + "permissions": ["scripting"], + "types": [ + { + "id": "ScriptInjection", + "type": "object", + "description": "Details of a script injection", + "properties": { + "args": { + "type": "array", + "optional": true, + "description": "The arguments to curry into a provided function. This is only valid if the func parameter is specified. These arguments must be JSON-serializable.", + "items": { "type": "any" } + }, + "files": { + "type": "array", + "optional": true, + "description": "The path of the JS files to inject, relative to the extension's root directory. Exactly one of files and func must be specified.", + "minItems": 1, + "items": { "type": "string" } + }, + "func": { + "type": "function", + "optional": true, + "description": "A JavaScript function to inject. This function will be serialized, and then deserialized for injection. This means that any bound parameters and execution context will be lost. Exactly one of files and func must be specified." + }, + "target": { + "$ref": "InjectionTarget", + "description": "Details specifying the target into which to inject the script." + }, + "world": { + "$ref": "ExecutionWorld", + "optional": true + }, + "injectImmediately": { + "type": "boolean", + "optional": true, + "description": "Whether the injection should be triggered in the target as soon as possible (but not necessarily prior to page load)." + } + } + }, + { + "id": "InjectionResult", + "type": "object", + "description": "Result of a script injection.", + "properties": { + "frameId": { + "type": "integer", + "description": "The frame ID associated with the injection." + }, + "result": { + "type": "any", + "optional": true, + "description": "The result of the script execution." + }, + "error": { + "type": "any", + "optional": true, + "description": "The error property is set when the script execution failed. The value is typically an (Error) object with a message property, but could be any value (including primitives and undefined) if the script threw or rejected with such a value." + } + } + }, + { + "id": "InjectionTarget", + "type": "object", + "properties": { + "frameIds": { + "type": "array", + "optional": true, + "description": "The IDs of specific frames to inject into.", + "items": { "type": "number" } + }, + "allFrames": { + "type": "boolean", + "optional": true, + "description": "Whether the script should inject into all frames within the tab. Defaults to false. This must not be true if frameIds is specified." + }, + "tabId": { + "type": "number", + "description": "The ID of the tab into which to inject." + } + } + }, + { + "id": "CSSInjection", + "type": "object", + "properties": { + "css": { + "type": "string", + "optional": true, + "description": "A string containing the CSS to inject. Exactly one of files and css must be specified." + }, + "files": { + "type": "array", + "optional": true, + "description": "The path of the CSS files to inject, relative to the extension's root directory. Exactly one of files and css must be specified.", + "minItems": 1, + "items": { "type": "string" } + }, + "origin": { + "type": "string", + "optional": true, + "enum": ["USER", "AUTHOR"], + "default": "AUTHOR", + "description": "The style origin for the injection. Defaults to 'AUTHOR'." + }, + "target": { + "$ref": "InjectionTarget", + "description": "Details specifying the target into which to inject the CSS." + } + } + }, + { + "id": "ContentScriptFilter", + "type": "object", + "properties": { + "ids": { + "type": "array", + "optional": true, + "description": "The IDs of specific scripts to retrieve with getRegisteredContentScripts() or to unregister with unregisterContentScripts().", + "items": { "type": "string" } + } + } + }, + { + "id": "ExecutionWorld", + "type": "string", + "enum": ["ISOLATED"], + "description": "The JavaScript world for a script to execute within. We currently only support the 'ISOLATED' world." + }, + { + "id": "RegisteredContentScript", + "type": "object", + "properties": { + "allFrames": { + "type": "boolean", + "optional": true, + "description": "If specified true, it will inject into all frames, even if the frame is not the top-most frame in the tab. Each frame is checked independently for URL requirements; it will not inject into child frames if the URL requirements are not met. Defaults to false, meaning that only the top frame is matched." + }, + "excludeMatches": { + "type": "array", + "optional": true, + "description": "Excludes pages that this content script would otherwise be injected into.", + "items": { "type": "string" } + }, + "id": { + "type": "string", + "description": "The id of the content script, specified in the API call." + }, + "js": { + "type": "array", + "optional": true, + "description": "The list of JavaScript files to be injected into matching pages. These are injected in the order they appear in this array.", + "items": { "$ref": "manifest.ExtensionURL" } + }, + "matches": { + "type": "array", + "optional": true, + "description": "Specifies which pages this content script will be injected into. Must be specified for registerContentScripts().", + "items": { "type": "string" } + }, + "runAt": { + "$ref": "extensionTypes.RunAt", + "optional": true, + "description": "Specifies when JavaScript files are injected into the web page. The preferred and default value is document_idle." + }, + "persistAcrossSessions": { + "type": "boolean", + "optional": true, + "default": true, + "description": "Specifies if this content script will persist into future sessions. Defaults to true." + }, + "css": { + "type": "array", + "optional": true, + "description": "The list of CSS files to be injected into matching pages. These are injected in the order they appear in this array.", + "items": { "$ref": "manifest.ExtensionURL" } + } + } + } + ], + "functions": [ + { + "name": "executeScript", + "type": "function", + "description": "Injects a script into a target context. The script will be run at document_idle.", + "async": "callback", + "parameters": [ + { + "name": "injection", + "$ref": "ScriptInjection", + "description": "The details of the script which to inject." + }, + { + "name": "callback", + "type": "function", + "description": "Invoked upon completion of the injection. The resulting array contains the result of execution for each frame where the injection succeeded.", + "parameters": [ + { + "name": "results", + "type": "array", + "items": { "$ref": "InjectionResult" } + } + ] + } + ] + }, + { + "name": "insertCSS", + "type": "function", + "description": "Inserts a CSS stylesheet into a target context. If multiple frames are specified, unsuccessful injections are ignored.", + "async": "callback", + "parameters": [ + { + "name": "injection", + "$ref": "CSSInjection", + "description": "The details of the styles to insert." + }, + { + "name": "callback", + "type": "function", + "description": "Invoked upon completion of the injection.", + "parameters": [] + } + ] + }, + { + "name": "removeCSS", + "type": "function", + "description": "Removes a CSS stylesheet that was previously inserted by this extension from a target context.", + "async": "callback", + "parameters": [ + { + "name": "injection", + "$ref": "CSSInjection", + "description": "The details of the styles to remove. Note that the css, files, and origin properties must exactly match the stylesheet inserted through insertCSS. Attempting to remove a non-existent stylesheet is a no-op." + }, + { + "name": "callback", + "type": "function", + "description": "Invoked upon completion of the injection.", + "parameters": [] + } + ] + }, + { + "name": "registerContentScripts", + "type": "function", + "description": "Registers one or more content scripts for this extension.", + "async": "callback", + "parameters": [ + { + "name": "scripts", + "type": "array", + "description": "Contains a list of scripts to be registered. If there are errors during script parsing/file validation, or if the IDs specified already exist, then no scripts are registered.", + "items": { "$ref": "RegisteredContentScript" } + }, + { + "name": "callback", + "type": "function", + "description": "Invoked upon completion of the registration.", + "parameters": [] + } + ] + }, + { + "name": "getRegisteredContentScripts", + "type": "function", + "description": "Returns all dynamically registered content scripts for this extension that match the given filter.", + "async": "callback", + "parameters": [ + { + "name": "filter", + "$ref": "ContentScriptFilter", + "optional": true, + "description": "An object to filter the extension's dynamically registered scripts." + }, + { + "name": "callback", + "type": "function", + "description": "The resulting array contains the registered content scripts.", + "parameters": [ + { + "name": "scripts", + "type": "array", + "items": { "$ref": "RegisteredContentScript" } + } + ] + } + ] + }, + { + "name": "unregisterContentScripts", + "type": "function", + "description": "Unregisters one or more content scripts for this extension.", + "async": "callback", + "parameters": [ + { + "name": "filter", + "$ref": "ContentScriptFilter", + "optional": true, + "description": "If specified, only unregisters dynamic content scripts which match the filter. Otherwise, all of the extension's dynamic content scripts are unregistered." + }, + { + "name": "callback", + "type": "function", + "description": "Invoked upon completion of the unregistration.", + "parameters": [] + } + ] + }, + { + "name": "updateContentScripts", + "type": "function", + "description": "Updates one or more content scripts for this extension.", + "async": "callback", + "parameters": [ + { + "name": "scripts", + "type": "array", + "description": "Contains a list of scripts to be updated. If there are errors during script parsing/file validation, or if the IDs specified do not already exist, then no scripts are updated.", + "items": { + "type": "object", + "$import": "RegisteredContentScript", + "properties": { + "persistAcrossSessions": { + "type": "boolean", + "optional": true, + "description": "Specifies if this content script will persist into future sessions." + } + } + } + }, + { + "name": "callback", + "type": "function", + "description": "Invoked when scripts have been updated.", + "parameters": [] + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/storage.json b/toolkit/components/extensions/schemas/storage.json new file mode 100644 index 0000000000..56649fbbc6 --- /dev/null +++ b/toolkit/components/extensions/schemas/storage.json @@ -0,0 +1,394 @@ +[ + { + "namespace": "storage", + "allowedContexts": ["content", "devtools"], + "defaultContexts": ["content", "devtools"], + "description": "Use the browser.storage API to store, retrieve, and track changes to user data.", + "permissions": ["storage"], + "types": [ + { + "id": "StorageChange", + "type": "object", + "properties": { + "oldValue": { + "type": "any", + "description": "The old value of the item, if there was an old value.", + "optional": true + }, + "newValue": { + "type": "any", + "description": "The new value of the item, if there is a new value.", + "optional": true + } + } + }, + { + "id": "StorageArea", + "type": "object", + "functions": [ + { + "name": "get", + "type": "function", + "description": "Gets one or more items from storage.", + "async": "callback", + "parameters": [ + { + "name": "keys", + "choices": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } }, + { + "type": "object", + "description": "Storage items to return in the callback, where the values are replaced with those from storage if they exist.", + "additionalProperties": { "type": "any" } + } + ], + "description": "A single key to get, list of keys to get, or a dictionary specifying default values (see description of the object). An empty list or object will return an empty result object. Pass in null to get the entire contents of storage.", + "optional": true + }, + { + "name": "callback", + "type": "function", + "description": "Callback with storage items, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [ + { + "name": "items", + "type": "object", + "additionalProperties": { "type": "any" }, + "description": "Object with items in their key-value mappings." + } + ] + } + ] + }, + { + "name": "getBytesInUse", + "unsupported": true, + "type": "function", + "description": "Gets the amount of space (in bytes) being used by one or more items.", + "async": "callback", + "parameters": [ + { + "name": "keys", + "choices": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ], + "description": "A single key or list of keys to get the total usage for. An empty list will return 0. Pass in null to get the total usage of all of storage.", + "optional": true + }, + { + "name": "callback", + "type": "function", + "description": "Callback with the amount of space being used by storage, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [ + { + "name": "bytesInUse", + "type": "integer", + "description": "Amount of space being used in storage, in bytes." + } + ] + } + ] + }, + { + "name": "set", + "type": "function", + "description": "Sets multiple items.", + "async": "callback", + "parameters": [ + { + "name": "items", + "type": "object", + "additionalProperties": { "type": "any" }, + "description": "

An object which gives each key/value pair to update storage with. Any other key/value pairs in storage will not be affected.

Primitive values such as numbers will serialize as expected. Values with a typeof \"object\" and \"function\" will typically serialize to {}, with the exception of Array (serializes as expected), Date, and Regex (serialize using their String representation).

" + }, + { + "name": "callback", + "type": "function", + "description": "Callback on success, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [], + "optional": true + } + ] + }, + { + "name": "remove", + "type": "function", + "description": "Removes one or more items from storage.", + "async": "callback", + "parameters": [ + { + "name": "keys", + "choices": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ], + "description": "A single key or a list of keys for items to remove." + }, + { + "name": "callback", + "type": "function", + "description": "Callback on success, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [], + "optional": true + } + ] + }, + { + "name": "clear", + "type": "function", + "description": "Removes all items from storage.", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "description": "Callback on success, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [], + "optional": true + } + ] + } + ], + "events": [ + { + "name": "onChanged", + "type": "function", + "description": "Fired when one or more items change.", + "parameters": [ + { + "name": "changes", + "type": "object", + "additionalProperties": { "$ref": "StorageChange" }, + "description": "Object mapping each key that changed to its corresponding $(ref:storage.StorageChange) for that item." + } + ] + } + ] + }, + { + "id": "StorageAreaSync", + "type": "object", + "functions": [ + { + "name": "get", + "type": "function", + "description": "Gets one or more items from storage.", + "async": "callback", + "parameters": [ + { + "name": "keys", + "choices": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } }, + { + "type": "object", + "description": "Storage items to return in the callback, where the values are replaced with those from storage if they exist.", + "additionalProperties": { "type": "any" } + } + ], + "description": "A single key to get, list of keys to get, or a dictionary specifying default values (see description of the object). An empty list or object will return an empty result object. Pass in null to get the entire contents of storage.", + "optional": true + }, + { + "name": "callback", + "type": "function", + "description": "Callback with storage items, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [ + { + "name": "items", + "type": "object", + "additionalProperties": { "type": "any" }, + "description": "Object with items in their key-value mappings." + } + ] + } + ] + }, + { + "name": "getBytesInUse", + "type": "function", + "description": "Gets the amount of space (in bytes) being used by one or more items.", + "async": "callback", + "parameters": [ + { + "name": "keys", + "choices": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ], + "description": "A single key or list of keys to get the total usage for. An empty list will return 0. Pass in null to get the total usage of all of storage.", + "optional": true + }, + { + "name": "callback", + "type": "function", + "description": "Callback with the amount of space being used by storage, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [ + { + "name": "bytesInUse", + "type": "integer", + "description": "Amount of space being used in storage, in bytes." + } + ] + } + ] + }, + { + "name": "set", + "type": "function", + "description": "Sets multiple items.", + "async": "callback", + "parameters": [ + { + "name": "items", + "type": "object", + "additionalProperties": { "type": "any" }, + "description": "

An object which gives each key/value pair to update storage with. Any other key/value pairs in storage will not be affected.

Primitive values such as numbers will serialize as expected. Values with a typeof \"object\" and \"function\" will typically serialize to {}, with the exception of Array (serializes as expected), Date, and Regex (serialize using their String representation).

" + }, + { + "name": "callback", + "type": "function", + "description": "Callback on success, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [], + "optional": true + } + ] + }, + { + "name": "remove", + "type": "function", + "description": "Removes one or more items from storage.", + "async": "callback", + "parameters": [ + { + "name": "keys", + "choices": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ], + "description": "A single key or a list of keys for items to remove." + }, + { + "name": "callback", + "type": "function", + "description": "Callback on success, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [], + "optional": true + } + ] + }, + { + "name": "clear", + "type": "function", + "description": "Removes all items from storage.", + "async": "callback", + "parameters": [ + { + "name": "callback", + "type": "function", + "description": "Callback on success, or on failure (in which case $(ref:runtime.lastError) will be set).", + "parameters": [], + "optional": true + } + ] + } + ], + "events": [ + { + "name": "onChanged", + "type": "function", + "description": "Fired when one or more items change.", + "parameters": [ + { + "name": "changes", + "type": "object", + "additionalProperties": { "$ref": "StorageChange" }, + "description": "Object mapping each key that changed to its corresponding $(ref:storage.StorageChange) for that item." + } + ] + } + ] + } + ], + "events": [ + { + "name": "onChanged", + "type": "function", + "description": "Fired when one or more items change.", + "parameters": [ + { + "name": "changes", + "type": "object", + "additionalProperties": { "$ref": "StorageChange" }, + "description": "Object mapping each key that changed to its corresponding $(ref:storage.StorageChange) for that item." + }, + { + "name": "areaName", + "type": "string", + "description": "The name of the storage area (\"sync\", \"local\" or \"managed\") the changes are for." + } + ] + } + ], + "properties": { + "sync": { + "$ref": "StorageAreaSync", + "description": "Items in the sync storage area are synced by the browser.", + "properties": { + "QUOTA_BYTES": { + "value": 102400, + "description": "The maximum total amount (in bytes) of data that can be stored in sync storage, as measured by the JSON stringification of every value plus every key's length. Updates that would cause this limit to be exceeded fail immediately and set $(ref:runtime.lastError)." + }, + "QUOTA_BYTES_PER_ITEM": { + "value": 8192, + "description": "The maximum size (in bytes) of each individual item in sync storage, as measured by the JSON stringification of its value plus its key length. Updates containing items larger than this limit will fail immediately and set $(ref:runtime.lastError)." + }, + "MAX_ITEMS": { + "value": 512, + "description": "The maximum number of items that can be stored in sync storage. Updates that would cause this limit to be exceeded will fail immediately and set $(ref:runtime.lastError)." + }, + "MAX_WRITE_OPERATIONS_PER_HOUR": { + "value": 1800, + "description": "

The maximum number of set, remove, or clear operations that can be performed each hour. This is 1 every 2 seconds, a lower ceiling than the short term higher writes-per-minute limit.

Updates that would cause this limit to be exceeded fail immediately and set $(ref:runtime.lastError).

" + }, + "MAX_WRITE_OPERATIONS_PER_MINUTE": { + "value": 120, + "description": "

The maximum number of set, remove, or clear operations that can be performed each minute. This is 2 per second, providing higher throughput than writes-per-hour over a shorter period of time.

Updates that would cause this limit to be exceeded fail immediately and set $(ref:runtime.lastError).

" + }, + "MAX_SUSTAINED_WRITE_OPERATIONS_PER_MINUTE": { + "value": 1000000, + "deprecated": "The storage.sync API no longer has a sustained write operation quota.", + "description": "" + } + } + }, + "local": { + "$ref": "StorageArea", + "description": "Items in the local storage area are local to each machine.", + "properties": { + "QUOTA_BYTES": { + "value": 5242880, + "description": "The maximum amount (in bytes) of data that can be stored in local storage, as measured by the JSON stringification of every value plus every key's length. This value will be ignored if the extension has the unlimitedStorage permission. Updates that would cause this limit to be exceeded fail immediately and set $(ref:runtime.lastError)." + } + } + }, + "managed": { + "$ref": "StorageArea", + "description": "Items in the managed storage area are set by administrators or native applications, and are read-only for the extension; trying to modify this namespace results in an error.", + "properties": { + "QUOTA_BYTES": { + "value": 5242880, + "description": "The maximum size (in bytes) of the managed storage JSON manifest file. Files larger than this limit will fail to load." + } + } + }, + "session": { + "allowedContexts": ["devtools"], + "$ref": "StorageArea", + "description": "Items in the session storage area are kept in memory, and only until the either browser or extension is closed or reloaded." + } + } + } +] diff --git a/toolkit/components/extensions/schemas/telemetry.json b/toolkit/components/extensions/schemas/telemetry.json new file mode 100644 index 0000000000..da8587f7e7 --- /dev/null +++ b/toolkit/components/extensions/schemas/telemetry.json @@ -0,0 +1,469 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "WebExtensionManifest", + "properties": { + "telemetry": { + "type": "object", + "optional": true, + "additionalProperties": { "$ref": "UnrecognizedProperty" }, + "properties": { + "ping_type": { + "type": "string" + }, + "schemaNamespace": { + "type": "string" + }, + "public_key": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "key": { + "type": "object", + "properties": { + "crv": { + "type": "string", + "optional": "false" + }, + "kty": { + "type": "string", + "optional": "false" + }, + "x": { + "type": "string", + "optional": "false" + }, + "y": { + "type": "string", + "optional": "false" + } + } + } + } + }, + "study_name": { + "type": "string", + "optional": true + }, + "pioneer_id": { + "type": "boolean", + "optional": true, + "default": false + } + } + } + } + }, + { + "$extend": "PermissionPrivileged", + "choices": [ + { + "type": "string", + "enum": ["telemetry"] + } + ] + } + ] + }, + { + "namespace": "telemetry", + "description": "Use the browser.telemetry API to send telemetry data to the Mozilla Telemetry service. Restricted to Mozilla privileged webextensions.", + "types": [ + { + "id": "ScalarType", + "type": "string", + "enum": ["count", "string", "boolean"], + "description": "Type of scalar: 'count' for numeric values, 'string' for string values, 'boolean' for boolean values. Maps to nsITelemetry.SCALAR_TYPE_*." + }, + { + "id": "ScalarData", + "type": "object", + "description": "Represents registration data for a Telemetry scalar.", + "properties": { + "kind": { + "$ref": "ScalarType" + }, + "keyed": { + "type": "boolean", + "optional": true, + "default": false, + "description": "True if this is a keyed scalar." + }, + "record_on_release": { + "type": "boolean", + "optional": true, + "default": false, + "description": "True if this data should be recorded on release." + }, + "expired": { + "type": "boolean", + "optional": true, + "default": false, + "description": "True if this scalar entry is expired. This allows recording it without error, but it will be discarded." + } + } + }, + { + "id": "EventData", + "type": "object", + "description": "Represents registration data for a Telemetry event.", + "properties": { + "methods": { + "type": "array", + "items": { "type": "string" }, + "description": "List of methods for this event entry." + }, + "objects": { + "type": "array", + "items": { "type": "string" }, + "description": "List of objects for this event entry." + }, + "extra_keys": { + "type": "array", + "items": { "type": "string" }, + "description": "List of allowed extra keys for this event entry." + }, + "record_on_release": { + "type": "boolean", + "optional": true, + "default": false, + "description": "True if this data should be recorded on release." + }, + "expired": { + "type": "boolean", + "optional": true, + "default": false, + "description": "True if this event entry is expired. This allows recording it without error, but it will be discarded." + } + } + } + ], + "permissions": ["telemetry"], + "functions": [ + { + "name": "submitPing", + "type": "function", + "description": "Submits a custom ping to the Telemetry back-end. See submitExternalPing inside TelemetryController.sys.mjs for more details.", + "async": true, + "parameters": [ + { + "name": "type", + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]+[a-z0-9]$", + "description": "The type of the ping." + }, + { + "name": "message", + "type": "object", + "additionalProperties": { "type": "any" }, + "description": "The data payload for the ping." + }, + { + "description": "Options object.", + "name": "options", + "type": "object", + "properties": { + "addClientId": { + "type": "boolean", + "optional": true, + "default": false, + "description": "True if the ping should contain the client id." + }, + "addEnvironment": { + "type": "boolean", + "optional": true, + "default": false, + "description": "True if the ping should contain the environment data." + }, + "overrideEnvironment": { + "type": "object", + "additionalProperties": { "type": "any" }, + "optional": true, + "default": false, + "description": "Set to override the environment data." + }, + "usePingSender": { + "type": "boolean", + "optional": true, + "default": false, + "description": "If true, send the ping using the PingSender." + } + } + } + ] + }, + { + "name": "submitEncryptedPing", + "type": "function", + "description": "Submits a custom ping to the Telemetry back-end, with an encrypted payload. Requires a telemetry entry in the manifest to be used.", + "parameters": [ + { + "name": "message", + "type": "object", + "additionalProperties": { "type": "any" }, + "description": "The data payload for the ping, which will be encrypted." + }, + { + "description": "Options object.", + "name": "options", + "type": "object", + "properties": { + "schemaName": { + "type": "string", + "optional": false, + "description": "Schema name used for payload." + }, + "schemaVersion": { + "type": "integer", + "optional": false, + "description": "Schema version used for payload." + } + } + } + ], + "async": true + }, + { + "name": "canUpload", + "type": "function", + "description": "Checks if Telemetry upload is enabled.", + "parameters": [], + "async": true + }, + { + "name": "scalarAdd", + "type": "function", + "description": "Adds the value to the given scalar.", + "async": true, + "parameters": [ + { + "name": "name", + "type": "string", + "description": "The scalar name." + }, + { + "name": "value", + "type": "integer", + "minimum": 1, + "description": "The numeric value to add to the scalar. Only unsigned integers supported." + } + ] + }, + { + "name": "scalarSet", + "type": "function", + "description": "Sets the named scalar to the given value. Throws if the value type doesn't match the scalar type.", + "async": true, + "parameters": [ + { + "name": "name", + "type": "string", + "description": "The scalar name" + }, + { + "name": "value", + "description": "The value to set the scalar to", + "choices": [ + { "type": "string" }, + { "type": "boolean" }, + { "type": "integer" }, + { "type": "object", "additionalProperties": { "type": "any" } } + ] + } + ] + }, + { + "name": "scalarSetMaximum", + "type": "function", + "description": "Sets the scalar to the maximum of the current and the passed value", + "async": true, + "parameters": [ + { + "name": "name", + "type": "string", + "description": "The scalar name." + }, + { + "name": "value", + "type": "integer", + "minimum": 0, + "description": "The numeric value to set the scalar to. Only unsigned integers supported." + } + ] + }, + { + "name": "keyedScalarAdd", + "type": "function", + "description": "Adds the value to the given keyed scalar.", + "async": true, + "parameters": [ + { + "name": "name", + "type": "string", + "description": "The scalar name" + }, + { + "name": "key", + "type": "string", + "description": "The key name" + }, + { + "name": "value", + "type": "integer", + "minimum": 1, + "description": "The numeric value to add to the scalar. Only unsigned integers supported." + } + ] + }, + { + "name": "keyedScalarSet", + "type": "function", + "description": "Sets the keyed scalar to the given value. Throws if the value type doesn't match the scalar type.", + "async": true, + "parameters": [ + { + "name": "name", + "type": "string", + "description": "The scalar name." + }, + { + "name": "key", + "type": "string", + "description": "The key name." + }, + { + "name": "value", + "description": "The value to set the scalar to.", + "choices": [ + { "type": "string" }, + { "type": "boolean" }, + { "type": "integer" }, + { "type": "object", "additionalProperties": { "type": "any" } } + ] + } + ] + }, + { + "name": "keyedScalarSetMaximum", + "type": "function", + "description": "Sets the keyed scalar to the maximum of the current and the passed value", + "async": true, + "parameters": [ + { + "name": "name", + "type": "string", + "description": "The scalar name." + }, + { + "name": "key", + "type": "string", + "description": "The key name." + }, + { + "name": "value", + "type": "integer", + "minimum": 0, + "description": "The numeric value to set the scalar to. Only unsigned integers supported." + } + ] + }, + { + "name": "recordEvent", + "type": "function", + "description": "Record an event in Telemetry. Throws when trying to record an unknown event.", + "async": true, + "parameters": [ + { + "name": "category", + "type": "string", + "description": "The category name." + }, + { + "name": "method", + "type": "string", + "description": "The method name." + }, + { + "name": "object", + "type": "string", + "description": "The object name." + }, + { + "name": "value", + "type": "string", + "optional": true, + "description": "An optional string value to record." + }, + { + "name": "extra", + "type": "object", + "optional": true, + "description": "An optional object of the form (string -> string). It should only contain registered extra keys.", + "additionalProperties": { "type": "string" } + } + ] + }, + + { + "name": "registerScalars", + "type": "function", + "description": "Register new scalars to record them from addons. See nsITelemetry.idl for more details.", + "async": true, + "parameters": [ + { + "name": "category", + "type": "string", + "description": "The unique category the scalars are registered in." + }, + { + "name": "data", + "type": "object", + "additionalProperties": { "$ref": "ScalarData" }, + "description": "An object that contains registration data for multiple scalars. Each property name is the scalar name, and the corresponding property value is an object of ScalarData type." + } + ] + }, + { + "name": "registerEvents", + "type": "function", + "description": "Register new events to record them from addons. See nsITelemetry.idl for more details.", + "async": true, + "parameters": [ + { + "name": "category", + "type": "string", + "description": "The unique category the events are registered in." + }, + { + "name": "data", + "type": "object", + "additionalProperties": { "$ref": "EventData" }, + "description": "An object that contains registration data for 1+ events. Each property name is the category name, and the corresponding property value is an object of EventData type." + } + ] + }, + { + "name": "setEventRecordingEnabled", + "type": "function", + "description": "Enable recording of events in a category. Events default to recording disabled. This allows to toggle recording for all events in the specified category.", + "async": true, + "parameters": [ + { + "name": "category", + "type": "string", + "description": "The category name." + }, + { + "name": "enabled", + "type": "boolean", + "description": "Whether recording is enabled for events in that category." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/test.json b/toolkit/components/extensions/schemas/test.json new file mode 100644 index 0000000000..d0327a9e63 --- /dev/null +++ b/toolkit/components/extensions/schemas/test.json @@ -0,0 +1,206 @@ +[ + { + "namespace": "test", + "allowedContexts": ["content", "devtools"], + "defaultContexts": ["content", "devtools"], + "description": "none", + "functions": [ + { + "name": "withHandlingUserInput", + "type": "function", + "description": "Calls the callback function wrapped with user input set. This is only used for internal unit testing.", + "parameters": [{ "type": "function", "name": "callback" }] + }, + { + "name": "notifyFail", + "type": "function", + "description": "Notifies the browser process that test code running in the extension failed. This is only used for internal unit testing.", + "parameters": [{ "type": "string", "name": "message" }] + }, + { + "name": "notifyPass", + "type": "function", + "description": "Notifies the browser process that test code running in the extension passed. This is only used for internal unit testing.", + "parameters": [ + { "type": "string", "name": "message", "optional": true } + ] + }, + { + "name": "log", + "type": "function", + "description": "Logs a message during internal unit testing.", + "parameters": [{ "type": "string", "name": "message" }] + }, + { + "name": "sendMessage", + "type": "function", + "description": "Sends a string message to the browser process, generating a Notification that C++ test code can wait for.", + "allowAmbiguousOptionalArguments": true, + "parameters": [ + { "type": "any", "name": "arg1", "optional": true }, + { "type": "any", "name": "arg2", "optional": true } + ] + }, + { + "name": "fail", + "type": "function", + "parameters": [{ "type": "any", "name": "message", "optional": true }] + }, + { + "name": "succeed", + "type": "function", + "parameters": [{ "type": "any", "name": "message", "optional": true }] + }, + { + "name": "assertTrue", + "type": "function", + "allowAmbiguousOptionalArguments": true, + "parameters": [ + { "name": "test", "type": "any", "optional": true }, + { "type": "string", "name": "message", "optional": true } + ] + }, + { + "name": "assertFalse", + "type": "function", + "allowAmbiguousOptionalArguments": true, + "parameters": [ + { "name": "test", "type": "any", "optional": true }, + { "type": "string", "name": "message", "optional": true } + ] + }, + { + "name": "assertBool", + "type": "function", + "unsupported": true, + "parameters": [ + { + "name": "test", + "choices": [{ "type": "string" }, { "type": "boolean" }] + }, + { "type": "boolean", "name": "expected" }, + { "type": "string", "name": "message", "optional": true } + ] + }, + { + "name": "assertDeepEq", + "type": "function", + "allowAmbiguousOptionalArguments": true, + "parameters": [ + { "type": "any", "name": "expected" }, + { "type": "any", "name": "actual" }, + { "type": "string", "name": "message", "optional": true } + ] + }, + { + "name": "assertEq", + "type": "function", + "allowAmbiguousOptionalArguments": true, + "parameters": [ + { "type": "any", "name": "expected", "optional": true }, + { "type": "any", "name": "actual", "optional": true }, + { "type": "string", "name": "message", "optional": true } + ] + }, + { + "name": "assertNoLastError", + "type": "function", + "unsupported": true, + "parameters": [] + }, + { + "name": "assertLastError", + "type": "function", + "unsupported": true, + "parameters": [{ "type": "string", "name": "expectedError" }] + }, + { + "name": "assertRejects", + "type": "function", + "async": true, + "parameters": [ + { + "name": "promise", + "$ref": "Promise" + }, + { + "name": "expectedError", + "$ref": "ExpectedError" + }, + { + "name": "message", + "type": "string", + "optional": true + } + ] + }, + { + "name": "assertThrows", + "type": "function", + "parameters": [ + { + "name": "func", + "type": "function" + }, + { + "name": "expectedError", + "$ref": "ExpectedError" + }, + { + "name": "message", + "type": "string", + "optional": true + } + ] + } + ], + "types": [ + { + "id": "ExpectedError", + "choices": [ + { "type": "string" }, + { + "type": "object", + "isInstanceOf": "RegExp", + "additionalProperties": true + }, + { "type": "function" } + ] + }, + { + "id": "Promise", + "choices": [ + { + "type": "object", + "properties": { + "then": { "type": "function" } + }, + "additionalProperties": true + }, + { + "type": "object", + "isInstanceOf": "Promise", + "additionalProperties": true + } + ] + } + ], + "events": [ + { + "name": "onMessage", + "type": "function", + "description": "Used to test sending messages to extensions.", + "parameters": [ + { + "type": "string", + "name": "message" + }, + { + "type": "any", + "name": "argument" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/theme.json b/toolkit/components/extensions/schemas/theme.json new file mode 100644 index 0000000000..4cdd70aa19 --- /dev/null +++ b/toolkit/components/extensions/schemas/theme.json @@ -0,0 +1,457 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "PermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["theme"] + } + ] + }, + { + "id": "ThemeColor", + "choices": [ + { + "type": "string" + }, + { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + }, + { + "type": "array", + "minItems": 4, + "maxItems": 4, + "items": { + "type": "number" + } + } + ] + }, + { + "id": "ThemeExperiment", + "type": "object", + "properties": { + "stylesheet": { + "optional": true, + "$ref": "ExtensionURL" + }, + "images": { + "type": "object", + "optional": true, + "additionalProperties": { + "type": "string" + } + }, + "colors": { + "type": "object", + "optional": true, + "additionalProperties": { + "type": "string" + } + }, + "properties": { + "type": "object", + "optional": true, + "additionalProperties": { + "type": "string" + } + } + } + }, + { + "id": "ThemeType", + "type": "object", + "properties": { + "images": { + "type": "object", + "optional": true, + "properties": { + "additional_backgrounds": { + "type": "array", + "items": { "$ref": "ImageDataOrExtensionURL" }, + "maxItems": 15, + "optional": true + }, + "headerURL": { + "$ref": "ImageDataOrExtensionURL", + "optional": true, + "deprecated": "Unsupported images property, use 'theme.images.theme_frame', this alias is ignored in Firefox >= 70." + }, + "theme_frame": { + "$ref": "ImageDataOrExtensionURL", + "optional": true + } + }, + "additionalProperties": { "$ref": "ImageDataOrExtensionURL" } + }, + "colors": { + "type": "object", + "optional": true, + "properties": { + "tab_selected": { + "$ref": "ThemeColor", + "optional": true + }, + "accentcolor": { + "$ref": "ThemeColor", + "optional": true, + "deprecated": "Unsupported colors property, use 'theme.colors.frame', this alias is ignored in Firefox >= 70." + }, + "frame": { + "$ref": "ThemeColor", + "optional": true + }, + "frame_inactive": { + "$ref": "ThemeColor", + "optional": true + }, + "textcolor": { + "$ref": "ThemeColor", + "optional": true, + "deprecated": "Unsupported color property, use 'theme.colors.tab_background_text', this alias is ignored in Firefox >= 70." + }, + "tab_background_text": { + "$ref": "ThemeColor", + "optional": true + }, + "tab_background_separator": { + "$ref": "ThemeColor", + "optional": true + }, + "tab_loading": { + "$ref": "ThemeColor", + "optional": true + }, + "tab_text": { + "$ref": "ThemeColor", + "optional": true + }, + "tab_line": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_text": { + "$ref": "ThemeColor", + "optional": true, + "description": "This color property is an alias of 'bookmark_text'." + }, + "bookmark_text": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_field": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_field_text": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_field_border": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_field_separator": { + "$ref": "ThemeColor", + "optional": true, + "deprecated": "This color property is ignored in Firefox >= 89." + }, + "toolbar_top_separator": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_bottom_separator": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_vertical_separator": { + "$ref": "ThemeColor", + "optional": true + }, + "icons": { + "$ref": "ThemeColor", + "optional": true + }, + "icons_attention": { + "$ref": "ThemeColor", + "optional": true + }, + "button_background_hover": { + "$ref": "ThemeColor", + "optional": true + }, + "button_background_active": { + "$ref": "ThemeColor", + "optional": true + }, + "popup": { + "$ref": "ThemeColor", + "optional": true + }, + "popup_text": { + "$ref": "ThemeColor", + "optional": true + }, + "popup_border": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_field_focus": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_field_text_focus": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_field_border_focus": { + "$ref": "ThemeColor", + "optional": true + }, + "popup_highlight": { + "$ref": "ThemeColor", + "optional": true + }, + "popup_highlight_text": { + "$ref": "ThemeColor", + "optional": true + }, + "ntp_background": { + "$ref": "ThemeColor", + "optional": true + }, + "ntp_card_background": { + "$ref": "ThemeColor", + "optional": true + }, + "ntp_text": { + "$ref": "ThemeColor", + "optional": true + }, + "sidebar": { + "$ref": "ThemeColor", + "optional": true + }, + "sidebar_border": { + "$ref": "ThemeColor", + "optional": true + }, + "sidebar_text": { + "$ref": "ThemeColor", + "optional": true + }, + "sidebar_highlight": { + "$ref": "ThemeColor", + "optional": true + }, + "sidebar_highlight_text": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_field_highlight": { + "$ref": "ThemeColor", + "optional": true + }, + "toolbar_field_highlight_text": { + "$ref": "ThemeColor", + "optional": true + } + }, + "additionalProperties": { "$ref": "ThemeColor" } + }, + "properties": { + "type": "object", + "optional": true, + "properties": { + "additional_backgrounds_alignment": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "bottom", + "center", + "left", + "right", + "top", + "center bottom", + "center center", + "center top", + "left bottom", + "left center", + "left top", + "right bottom", + "right center", + "right top" + ] + }, + "maxItems": 15, + "optional": true + }, + "additional_backgrounds_tiling": { + "type": "array", + "items": { + "type": "string", + "enum": ["no-repeat", "repeat", "repeat-x", "repeat-y"] + }, + "maxItems": 15, + "optional": true + }, + "color_scheme": { + "optional": true, + "type": "string", + "enum": ["auto", "light", "dark", "system"] + }, + "content_color_scheme": { + "optional": true, + "type": "string", + "enum": ["auto", "light", "dark", "system"] + } + }, + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": { "$ref": "UnrecognizedProperty" } + }, + { + "id": "ThemeManifest", + "type": "object", + "description": "Contents of manifest.json for a static theme", + "$import": "manifest.ManifestBase", + "properties": { + "theme": { + "$ref": "ThemeType" + }, + "dark_theme": { + "$ref": "ThemeType", + "optional": true + }, + "default_locale": { + "type": "string", + "optional": true + }, + "theme_experiment": { + "$ref": "ThemeExperiment", + "optional": true + }, + "icons": { + "type": "object", + "optional": true, + "patternProperties": { + "^[1-9]\\d*$": { "type": "string" } + } + } + } + }, + { + "$extend": "WebExtensionManifest", + "properties": { + "theme_experiment": { + "$ref": "ThemeExperiment", + "optional": true + } + } + } + ] + }, + { + "namespace": "theme", + "description": "The theme API allows customizing of visual elements of the browser.", + "types": [ + { + "id": "ThemeUpdateInfo", + "type": "object", + "description": "Info provided in the onUpdated listener.", + "properties": { + "theme": { + "type": "object", + "description": "The new theme after update" + }, + "windowId": { + "type": "integer", + "description": "The id of the window the theme has been applied to", + "optional": true + } + } + } + ], + "events": [ + { + "name": "onUpdated", + "type": "function", + "description": "Fired when a new theme has been applied", + "parameters": [ + { + "$ref": "ThemeUpdateInfo", + "name": "updateInfo", + "description": "Details of the theme update" + } + ] + } + ], + "functions": [ + { + "name": "getCurrent", + "type": "function", + "async": true, + "description": "Returns the current theme for the specified window or the last focused window.", + "parameters": [ + { + "type": "integer", + "name": "windowId", + "optional": true, + "description": "The window for which we want the theme." + } + ] + }, + { + "name": "update", + "type": "function", + "async": true, + "description": "Make complete updates to the theme. Resolves when the update has completed.", + "permissions": ["theme"], + "parameters": [ + { + "type": "integer", + "name": "windowId", + "optional": true, + "description": "The id of the window to update. No id updates all windows." + }, + { + "name": "details", + "$ref": "manifest.ThemeType", + "description": "The properties of the theme to update." + } + ] + }, + { + "name": "reset", + "type": "function", + "async": true, + "description": "Removes the updates made to the theme.", + "permissions": ["theme"], + "parameters": [ + { + "type": "integer", + "name": "windowId", + "optional": true, + "description": "The id of the window to reset. No id resets all windows." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/types.json b/toolkit/components/extensions/schemas/types.json new file mode 100644 index 0000000000..c0dded5ad0 --- /dev/null +++ b/toolkit/components/extensions/schemas/types.json @@ -0,0 +1,168 @@ +[ + { + "namespace": "types", + "description": "Contains types used by other schemas.", + "types": [ + { + "id": "SettingScope", + "type": "string", + "enum": [ + "regular", + "regular_only", + "incognito_persistent", + "incognito_session_only" + ], + "description": "The scope of the Setting. One of
  • regular: setting for the regular profile (which is inherited by the incognito profile if not overridden elsewhere),
  • regular_only: setting for the regular profile only (not inherited by the incognito profile),
  • incognito_persistent: setting for the incognito profile that survives browser restarts (overrides regular preferences),
  • incognito_session_only: setting for the incognito profile that can only be set during an incognito session and is deleted when the incognito session ends (overrides regular and incognito_persistent preferences).
Only regular is supported by Firefox at this time." + }, + { + "id": "LevelOfControl", + "type": "string", + "enum": [ + "not_controllable", + "controlled_by_other_extensions", + "controllable_by_this_extension", + "controlled_by_this_extension" + ], + "description": "One of
  • not_controllable: cannot be controlled by any extension
  • controlled_by_other_extensions: controlled by extensions with higher precedence
  • controllable_by_this_extension: can be controlled by this extension
  • controlled_by_this_extension: controlled by this extension
" + }, + { + "id": "Setting", + "type": "object", + "functions": [ + { + "name": "get", + "type": "function", + "description": "Gets the value of a setting.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "description": "Which setting to consider.", + "properties": { + "incognito": { + "type": "boolean", + "optional": true, + "description": "Whether to return the value that applies to the incognito session (default false)." + } + } + }, + { + "name": "callback", + "type": "function", + "parameters": [ + { + "name": "details", + "type": "object", + "description": "Details of the currently effective value.", + "properties": { + "value": { + "description": "The value of the setting.", + "type": "any" + }, + "levelOfControl": { + "$ref": "types.LevelOfControl", + "description": "The level of control of the setting." + }, + "incognitoSpecific": { + "description": "Whether the effective value is specific to the incognito session.
This property will only be present if the incognito property in the details parameter of get() was true.", + "type": "boolean", + "optional": true + } + } + } + ] + } + ] + }, + { + "name": "set", + "type": "function", + "description": "Sets the value of a setting.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "description": "Which setting to change.", + "properties": { + "value": { + "description": "The value of the setting.
Note that every setting has a specific value type, which is described together with the setting. An extension should not set a value of a different type.", + "type": "any" + }, + "scope": { + "$ref": "types.SettingScope", + "optional": true, + "description": "Where to set the setting (default: regular)." + } + } + }, + { + "name": "callback", + "type": "function", + "description": "Called at the completion of the set operation.", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "clear", + "type": "function", + "description": "Clears the setting, restoring any default value.", + "async": "callback", + "parameters": [ + { + "name": "details", + "type": "object", + "description": "Which setting to clear.", + "properties": { + "scope": { + "$ref": "types.SettingScope", + "optional": true, + "description": "Where to clear the setting (default: regular)." + } + } + }, + { + "name": "callback", + "type": "function", + "description": "Called at the completion of the clear operation.", + "optional": true, + "parameters": [] + } + ] + } + ], + "events": [ + { + "name": "onChange", + "type": "function", + "description": "Fired after the setting changes.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "value": { + "description": "The value of the setting after the change.", + "type": "any" + }, + "levelOfControl": { + "$ref": "types.LevelOfControl", + "description": "The level of control of the setting." + }, + "incognitoSpecific": { + "description": "Whether the value that has changed is specific to the incognito session.
This property will only be present if the user has enabled the extension in incognito mode.", + "type": "boolean", + "optional": true + } + } + } + ] + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/user_scripts.json b/toolkit/components/extensions/schemas/user_scripts.json new file mode 100644 index 0000000000..35a66e53ed --- /dev/null +++ b/toolkit/components/extensions/schemas/user_scripts.json @@ -0,0 +1,132 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "WebExtensionManifest", + "properties": { + "user_scripts": { + "type": "object", + "max_manifest_version": 2, + "optional": true, + "properties": { + "api_script": { + "optional": true, + "$ref": "manifest.ExtensionURL" + } + }, + "additionalProperties": { "$ref": "UnrecognizedProperty" } + } + } + } + ] + }, + { + "namespace": "userScripts", + "max_manifest_version": 2, + "permissions": ["manifest:user_scripts"], + "types": [ + { + "id": "UserScriptOptions", + "type": "object", + "description": "Details of a user script", + "properties": { + "js": { + "type": "array", + "optional": false, + "description": "The list of JS files to inject", + "minItems": 1, + "items": { "$ref": "extensionTypes.ExtensionFileOrCode" } + }, + "scriptMetadata": { + "description": "An opaque user script metadata value", + "$ref": "extensionTypes.PlainJSONValue", + "optional": true + }, + "matches": { + "type": "array", + "optional": false, + "minItems": 1, + "items": { "$ref": "manifest.MatchPattern" } + }, + "excludeMatches": { + "type": "array", + "optional": true, + "minItems": 1, + "items": { "$ref": "manifest.MatchPattern" } + }, + "includeGlobs": { + "type": "array", + "optional": true, + "items": { "type": "string" } + }, + "excludeGlobs": { + "type": "array", + "optional": true, + "items": { "type": "string" } + }, + "allFrames": { + "type": "boolean", + "default": false, + "optional": true, + "description": "If allFrames is true, implies that the JavaScript should be injected into all frames of current page. By default, it's false and is only injected into the top frame." + }, + "matchAboutBlank": { + "type": "boolean", + "default": false, + "optional": true, + "description": "If matchAboutBlank is true, then the code is also injected in about:blank and about:srcdoc frames if your extension has access to its parent document. Code cannot be inserted in top-level about:-frames. By default it is false." + }, + "runAt": { + "$ref": "extensionTypes.RunAt", + "default": "document_idle", + "optional": true, + "description": "The soonest that the JavaScript will be injected into the tab. Defaults to \"document_idle\"." + }, + "cookieStoreId": { + "choices": [ + { + "type": "array", + "minItems": 1, + "items": { "type": "string" } + }, + { + "type": "string" + } + ], + "optional": true, + "description": "limit the set of matched tabs to those that belong to the given cookie store id" + } + } + }, + { + "id": "RegisteredUserScript", + "type": "object", + "description": "An object that represents a user script registered programmatically", + "functions": [ + { + "name": "unregister", + "type": "function", + "description": "Unregister a user script registered programmatically", + "async": true, + "parameters": [] + } + ] + } + ], + "functions": [ + { + "name": "register", + "type": "function", + "description": "Register a user script programmatically given its $(ref:userScripts.UserScriptOptions), and resolves to a $(ref:userScripts.RegisteredUserScript) instance", + "async": true, + "parameters": [ + { + "name": "userScriptOptions", + "$ref": "UserScriptOptions" + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/user_scripts_content.json b/toolkit/components/extensions/schemas/user_scripts_content.json new file mode 100644 index 0000000000..6e6581ed2e --- /dev/null +++ b/toolkit/components/extensions/schemas/user_scripts_content.json @@ -0,0 +1,58 @@ +[ + { + "namespace": "userScripts", + "max_manifest_version": 2, + "permissions": ["manifest:user_scripts"], + "allowedContexts": ["content"], + "events": [ + { + "name": "onBeforeScript", + "permissions": ["manifest:user_scripts.api_script"], + "allowedContexts": ["content", "content_only"], + "type": "function", + "description": "Event called when a new userScript global has been created", + "parameters": [ + { + "type": "object", + "name": "userScript", + "properties": { + "metadata": { + "type": "any", + "description": "The userScript metadata (as set in userScripts.register)" + }, + "global": { + "type": "any", + "description": "The userScript global" + }, + "defineGlobals": { + "type": "function", + "description": "Exports all the properties of a given plain object as userScript globals", + "parameters": [ + { + "type": "object", + "name": "sourceObject", + "description": "A plain object whose properties are exported as userScript globals" + } + ] + }, + "export": { + "type": "function", + "description": "Convert a given value to make it accessible to the userScript code", + "parameters": [ + { + "type": "any", + "name": "value", + "description": "A value to convert into an object accessible to the userScript" + } + ], + "returns": { + "type": "any" + } + } + } + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/web_navigation.json b/toolkit/components/extensions/schemas/web_navigation.json new file mode 100644 index 0000000000..c614a064bd --- /dev/null +++ b/toolkit/components/extensions/schemas/web_navigation.json @@ -0,0 +1,573 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermission", + "choices": [ + { + "type": "string", + "enum": ["webNavigation"] + } + ] + } + ] + }, + { + "namespace": "webNavigation", + "description": "Use the browser.webNavigation API to receive notifications about the status of navigation requests in-flight.", + "permissions": ["webNavigation"], + "types": [ + { + "id": "TransitionType", + "type": "string", + "enum": [ + "link", + "typed", + "auto_bookmark", + "auto_subframe", + "manual_subframe", + "generated", + "start_page", + "form_submit", + "reload", + "keyword", + "keyword_generated" + ], + "description": "Cause of the navigation. The same transition types as defined in the history API are used. These are the same transition types as defined in the $(topic:transition_types)[history API] except with \"start_page\" in place of \"auto_toplevel\" (for backwards compatibility)." + }, + { + "id": "TransitionQualifier", + "type": "string", + "enum": [ + "client_redirect", + "server_redirect", + "forward_back", + "from_address_bar" + ] + }, + { + "id": "EventUrlFilters", + "type": "object", + "properties": { + "url": { + "type": "array", + "minItems": 1, + "items": { "$ref": "events.UrlFilter" } + } + } + } + ], + "functions": [ + { + "name": "getFrame", + "type": "function", + "description": "Retrieves information about the given frame. A frame refers to an <iframe> or a <frame> of a web page and is identified by a tab ID and a frame ID.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Information about the frame to retrieve information about.", + "properties": { + "tabId": { + "type": "integer", + "minimum": 0, + "description": "The ID of the tab in which the frame is." + }, + "processId": { + "optional": true, + "type": "integer", + "description": "The ID of the process runs the renderer for this tab." + }, + "frameId": { + "type": "integer", + "minimum": 0, + "description": "The ID of the frame in the given tab." + } + } + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "type": "object", + "name": "details", + "optional": true, + "description": "Information about the requested frame, null if the specified frame ID and/or tab ID are invalid.", + "properties": { + "errorOccurred": { + "optional": true, + "type": "boolean", + "description": "True if the last navigation in this frame was interrupted by an error, i.e. the onErrorOccurred event fired." + }, + "url": { + "type": "string", + "description": "The URL currently associated with this frame, if the frame identified by the frameId existed at one point in the given tab. The fact that an URL is associated with a given frameId does not imply that the corresponding frame still exists." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the frame is." + }, + "frameId": { + "type": "integer", + "description": "The ID of the frame. 0 indicates that this is the main frame; a positive value indicates the ID of a subframe." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame. Set to -1 of no parent frame exists." + } + } + } + ] + } + ] + }, + { + "name": "getAllFrames", + "type": "function", + "description": "Retrieves information about all frames of a given tab.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "details", + "description": "Information about the tab to retrieve all frames from.", + "properties": { + "tabId": { + "type": "integer", + "minimum": 0, + "description": "The ID of the tab." + } + } + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "details", + "type": "array", + "description": "A list of frames in the given tab, null if the specified tab ID is invalid.", + "optional": true, + "items": { + "type": "object", + "properties": { + "errorOccurred": { + "optional": true, + "type": "boolean", + "description": "True if the last navigation in this frame was interrupted by an error, i.e. the onErrorOccurred event fired." + }, + "processId": { + "unsupported": true, + "type": "integer", + "description": "The ID of the process runs the renderer for this tab." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the frame is." + }, + "frameId": { + "type": "integer", + "description": "The ID of the frame. 0 indicates that this is the main frame; a positive value indicates the ID of a subframe." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame. Set to -1 of no parent frame exists." + }, + "url": { + "type": "string", + "description": "The URL currently associated with this frame." + } + } + } + } + ] + } + ] + } + ], + "events": [ + { + "name": "onBeforeNavigate", + "type": "function", + "description": "Fired when a navigation is about to occur.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the navigation is about to occur." + }, + "url": { "type": "string" }, + "processId": { + "unsupported": true, + "type": "integer", + "description": "The ID of the process runs the renderer for this tab." + }, + "frameId": { + "type": "integer", + "description": "0 indicates the navigation happens in the tab content window; a positive value indicates navigation in a subframe. Frame IDs are unique for a given tab and process." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame. Set to -1 of no parent frame exists." + }, + "timeStamp": { + "type": "number", + "description": "The time when the browser was about to start the navigation, in milliseconds since the epoch." + } + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + }, + { + "name": "onCommitted", + "type": "function", + "description": "Fired when a navigation is committed. The document (and the resources it refers to, such as images and subframes) might still be downloading, but at least part of the document has been received from the server and the browser has decided to switch to the new document.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the navigation occurs." + }, + "url": { "type": "string" }, + "processId": { + "unsupported": true, + "type": "integer", + "description": "The ID of the process runs the renderer for this tab." + }, + "frameId": { + "type": "integer", + "description": "0 indicates the navigation happens in the tab content window; a positive value indicates navigation in a subframe. Frame IDs are unique within a tab." + }, + "transitionType": { + "$ref": "TransitionType", + "description": "Cause of the navigation." + }, + "transitionQualifiers": { + "type": "array", + "description": "A list of transition qualifiers.", + "items": { "$ref": "TransitionQualifier" } + }, + "timeStamp": { + "type": "number", + "description": "The time when the navigation was committed, in milliseconds since the epoch." + } + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + }, + { + "name": "onDOMContentLoaded", + "type": "function", + "description": "Fired when the page's DOM is fully constructed, but the referenced resources may not finish loading.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the navigation occurs." + }, + "url": { "type": "string" }, + "processId": { + "unsupported": true, + "type": "integer", + "description": "The ID of the process runs the renderer for this tab." + }, + "frameId": { + "type": "integer", + "description": "0 indicates the navigation happens in the tab content window; a positive value indicates navigation in a subframe. Frame IDs are unique within a tab." + }, + "timeStamp": { + "type": "number", + "description": "The time when the page's DOM was fully constructed, in milliseconds since the epoch." + } + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + }, + { + "name": "onCompleted", + "type": "function", + "description": "Fired when a document, including the resources it refers to, is completely loaded and initialized.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the navigation occurs." + }, + "url": { "type": "string" }, + "processId": { + "unsupported": true, + "type": "integer", + "description": "The ID of the process runs the renderer for this tab." + }, + "frameId": { + "type": "integer", + "description": "0 indicates the navigation happens in the tab content window; a positive value indicates navigation in a subframe. Frame IDs are unique within a tab." + }, + "timeStamp": { + "type": "number", + "description": "The time when the document finished loading, in milliseconds since the epoch." + } + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + }, + { + "name": "onErrorOccurred", + "type": "function", + "description": "Fired when an error occurs and the navigation is aborted. This can happen if either a network error occurred, or the user aborted the navigation.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the navigation occurs." + }, + "url": { "type": "string" }, + "processId": { + "unsupported": true, + "type": "integer", + "description": "The ID of the process runs the renderer for this tab." + }, + "frameId": { + "type": "integer", + "description": "0 indicates the navigation happens in the tab content window; a positive value indicates navigation in a subframe. Frame IDs are unique within a tab." + }, + "error": { + "unsupported": true, + "type": "string", + "description": "The error description." + }, + "timeStamp": { + "type": "number", + "description": "The time when the error occurred, in milliseconds since the epoch." + } + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + }, + { + "name": "onCreatedNavigationTarget", + "type": "function", + "description": "Fired when a new window, or a new tab in an existing window, is created to host a navigation.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "sourceTabId": { + "type": "integer", + "description": "The ID of the tab in which the navigation is triggered." + }, + "sourceProcessId": { + "type": "integer", + "description": "The ID of the process runs the renderer for the source tab." + }, + "sourceFrameId": { + "type": "integer", + "description": "The ID of the frame with sourceTabId in which the navigation is triggered. 0 indicates the main frame." + }, + "url": { + "type": "string", + "description": "The URL to be opened in the new window." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the url is opened" + }, + "timeStamp": { + "type": "number", + "description": "The time when the browser was about to create a new view, in milliseconds since the epoch." + } + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + }, + { + "name": "onReferenceFragmentUpdated", + "type": "function", + "description": "Fired when the reference fragment of a frame was updated. All future events for that frame will use the updated URL.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the navigation occurs." + }, + "url": { "type": "string" }, + "processId": { + "unsupported": true, + "type": "integer", + "description": "The ID of the process runs the renderer for this tab." + }, + "frameId": { + "type": "integer", + "description": "0 indicates the navigation happens in the tab content window; a positive value indicates navigation in a subframe. Frame IDs are unique within a tab." + }, + "transitionType": { + "$ref": "TransitionType", + "description": "Cause of the navigation." + }, + "transitionQualifiers": { + "type": "array", + "description": "A list of transition qualifiers.", + "items": { "$ref": "TransitionQualifier" } + }, + "timeStamp": { + "type": "number", + "description": "The time when the navigation was committed, in milliseconds since the epoch." + } + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + }, + { + "name": "onTabReplaced", + "type": "function", + "description": "Fired when the contents of the tab is replaced by a different (usually previously pre-rendered) tab.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "replacedTabId": { + "type": "integer", + "description": "The ID of the tab that was replaced." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab that replaced the old tab." + }, + "timeStamp": { + "type": "number", + "description": "The time when the replacement happened, in milliseconds since the epoch." + } + } + } + ] + }, + { + "name": "onHistoryStateUpdated", + "type": "function", + "description": "Fired when the frame's history was updated to a new URL. All future events for that frame will use the updated URL.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the navigation occurs." + }, + "url": { "type": "string" }, + "processId": { + "unsupported": true, + "type": "integer", + "description": "The ID of the process runs the renderer for this tab." + }, + "frameId": { + "type": "integer", + "description": "0 indicates the navigation happens in the tab content window; a positive value indicates navigation in a subframe. Frame IDs are unique within a tab." + }, + "transitionType": { + "$ref": "TransitionType", + "description": "Cause of the navigation." + }, + "transitionQualifiers": { + "type": "array", + "description": "A list of transition qualifiers.", + "items": { "$ref": "TransitionQualifier" } + }, + "timeStamp": { + "type": "number", + "description": "The time when the navigation was committed, in milliseconds since the epoch." + } + } + } + ], + "extraParameters": [ + { + "name": "filters", + "optional": true, + "$ref": "EventUrlFilters", + "description": "Conditions that the URL being navigated to must satisfy. The 'schemes' and 'ports' fields of UrlFilter are ignored for this event." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/schemas/web_request.json b/toolkit/components/extensions/schemas/web_request.json new file mode 100644 index 0000000000..e4405f24c3 --- /dev/null +++ b/toolkit/components/extensions/schemas/web_request.json @@ -0,0 +1,1475 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": [ + "webRequest", + "webRequestBlocking", + "webRequestFilterResponse", + "webRequestFilterResponse.serviceWorkerScript" + ] + } + ] + } + ] + }, + { + "namespace": "webRequest", + "description": "Use the browser.webRequest API to observe and analyze traffic and to intercept, block, or modify requests in-flight.", + "permissions": ["webRequest"], + "properties": { + "MAX_HANDLER_BEHAVIOR_CHANGED_CALLS_PER_10_MINUTES": { + "value": 20, + "description": "The maximum number of times that handlerBehaviorChanged can be called per 10 minute sustained interval. handlerBehaviorChanged is an expensive function call that shouldn't be called often." + } + }, + "types": [ + { + "id": "ResourceType", + "type": "string", + "enum": [ + "main_frame", + "sub_frame", + "stylesheet", + "script", + "image", + "object", + "object_subrequest", + "xmlhttprequest", + "xslt", + "ping", + "beacon", + "xml_dtd", + "font", + "media", + "websocket", + "csp_report", + "imageset", + "web_manifest", + "speculative", + "other" + ] + }, + { + "id": "OnBeforeRequestOptions", + "type": "string", + "enum": ["blocking", "requestBody"], + "postprocess": "webRequestBlockingPermissionRequired" + }, + { + "id": "OnBeforeSendHeadersOptions", + "type": "string", + "enum": ["requestHeaders", "blocking"], + "postprocess": "webRequestBlockingPermissionRequired" + }, + { + "id": "OnSendHeadersOptions", + "type": "string", + "enum": ["requestHeaders"] + }, + { + "id": "OnHeadersReceivedOptions", + "type": "string", + "enum": ["blocking", "responseHeaders"], + "postprocess": "webRequestBlockingPermissionRequired" + }, + { + "id": "OnAuthRequiredOptions", + "type": "string", + "enum": ["responseHeaders", "blocking", "asyncBlocking"], + "postprocess": "webRequestBlockingPermissionRequired" + }, + { + "id": "OnResponseStartedOptions", + "type": "string", + "enum": ["responseHeaders"] + }, + { + "id": "OnBeforeRedirectOptions", + "type": "string", + "enum": ["responseHeaders"] + }, + { + "id": "OnCompletedOptions", + "type": "string", + "enum": ["responseHeaders"] + }, + { + "id": "RequestFilter", + "type": "object", + "description": "An object describing filters to apply to webRequest events.", + "properties": { + "urls": { + "type": "array", + "description": "A list of URLs or URL patterns. Requests that cannot match any of the URLs will be filtered out.", + "items": { "type": "string" }, + "minItems": 1 + }, + "types": { + "type": "array", + "optional": true, + "description": "A list of request types. Requests that cannot match any of the types will be filtered out.", + "items": { "$ref": "ResourceType", "onError": "warn" }, + "minItems": 1 + }, + "tabId": { "type": "integer", "optional": true }, + "windowId": { "type": "integer", "optional": true }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "If provided, requests that do not match the incognito state will be filtered out." + } + } + }, + { + "id": "HttpHeaders", + "type": "array", + "description": "An array of HTTP headers. Each header is represented as a dictionary containing the keys name and either value or binaryValue.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the HTTP header." + }, + "value": { + "type": "string", + "optional": true, + "description": "Value of the HTTP header if it can be represented by UTF-8." + }, + "binaryValue": { + "type": "array", + "optional": true, + "description": "Value of the HTTP header if it cannot be represented by UTF-8, stored as individual byte values (0..255).", + "items": { "type": "integer" } + } + } + } + }, + { + "id": "BlockingResponse", + "type": "object", + "description": "Returns value for event handlers that have the 'blocking' extraInfoSpec applied. Allows the event handler to modify network requests.", + "properties": { + "cancel": { + "type": "boolean", + "optional": true, + "description": "If true, the request is cancelled. Used in onBeforeRequest, this prevents the request from being sent." + }, + "redirectUrl": { + "type": "string", + "optional": true, + "description": "Only used as a response to the onBeforeRequest and onHeadersReceived events. If set, the original request is prevented from being sent/completed and is instead redirected to the given URL. Redirections to non-HTTP schemes such as data: are allowed. Redirects initiated by a redirect action use the original request method for the redirect, with one exception: If the redirect is initiated at the onHeadersReceived stage, then the redirect will be issued using the GET method." + }, + "upgradeToSecure": { + "type": "boolean", + "optional": true, + "description": "Only used as a response to the onBeforeRequest event. If set, the original request is prevented from being sent/completed and is instead upgraded to a secure request. If any extension returns redirectUrl during onBeforeRequest, upgradeToSecure will have no affect." + }, + "requestHeaders": { + "$ref": "HttpHeaders", + "optional": true, + "description": "Only used as a response to the onBeforeSendHeaders event. If set, the request is made with these request headers instead." + }, + "responseHeaders": { + "$ref": "HttpHeaders", + "optional": true, + "description": "Only used as a response to the onHeadersReceived event. If set, the server is assumed to have responded with these response headers instead. Only return responseHeaders if you really want to modify the headers in order to limit the number of conflicts (only one extension may modify responseHeaders for each request)." + }, + "authCredentials": { + "type": "object", + "description": "Only used as a response to the onAuthRequired event. If set, the request is made using the supplied credentials.", + "optional": true, + "properties": { + "username": { "type": "string" }, + "password": { "type": "string" } + } + } + } + }, + { + "id": "CertificateInfo", + "type": "object", + "description": "Contains the certificate properties of the request if it is a secure request.", + "properties": { + "subject": { + "type": "string" + }, + "issuer": { + "type": "string" + }, + "validity": { + "type": "object", + "description": "Contains start and end timestamps.", + "properties": { + "start": { "type": "integer" }, + "end": { "type": "integer" } + } + }, + "fingerprint": { + "type": "object", + "properties": { + "sha1": { "type": "string" }, + "sha256": { "type": "string" } + } + }, + "serialNumber": { + "type": "string" + }, + "isBuiltInRoot": { + "type": "boolean" + }, + "subjectPublicKeyInfoDigest": { + "type": "object", + "properties": { + "sha256": { "type": "string" } + } + }, + "rawDER": { + "optional": true, + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + { + "id": "CertificateTransparencyStatus", + "type": "string", + "enum": [ + "not_applicable", + "policy_compliant", + "policy_not_enough_scts", + "policy_not_diverse_scts" + ] + }, + { + "id": "TransportWeaknessReasons", + "type": "string", + "enum": ["cipher"] + }, + { + "id": "SecurityInfo", + "type": "object", + "description": "Contains the security properties of the request (ie. SSL/TLS information).", + "properties": { + "state": { + "type": "string", + "enum": ["insecure", "weak", "broken", "secure"] + }, + "errorMessage": { + "type": "string", + "description": "Error message if state is \"broken\"", + "optional": true + }, + "protocolVersion": { + "type": "string", + "description": "Protocol version if state is \"secure\"", + "enum": ["TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3", "unknown"], + "optional": true + }, + "cipherSuite": { + "type": "string", + "description": "The cipher suite used in this request if state is \"secure\".", + "optional": true + }, + "keaGroupName": { + "type": "string", + "description": "The key exchange algorithm used in this request if state is \"secure\".", + "optional": true + }, + "secretKeyLength": { + "type": "number", + "description": "The length (in bits) of the secret key.", + "optional": true + }, + "signatureSchemeName": { + "type": "string", + "description": "The signature scheme used in this request if state is \"secure\".", + "optional": true + }, + "certificates": { + "description": "Certificate data if state is \"secure\". Will only contain one entry unless certificateChain is passed as an option.", + "type": "array", + "items": { "$ref": "CertificateInfo" } + }, + "overridableErrorCategory": { + "description": "The type of certificate error that was overridden for this connection, if any.", + "type": "string", + "enum": [ + "trust_error", + "domain_mismatch", + "expired_or_not_yet_valid" + ], + "optional": true + }, + "isDomainMismatch": { + "description": "The domain name does not match the certificate domain.", + "type": "boolean", + "optional": true, + "deprecated": "Please use $(ref:SecurityInfo.overridableErrorCategory)." + }, + "isNotValidAtThisTime": { + "description": "The certificate is either expired or is not yet valid. See CertificateInfo.validity for start and end dates.", + "type": "boolean", + "optional": true, + "deprecated": "Please use $(ref:SecurityInfo.overridableErrorCategory)." + }, + "isUntrusted": { + "type": "boolean", + "optional": true, + "deprecated": "Please use $(ref:SecurityInfo.overridableErrorCategory)." + }, + "isExtendedValidation": { + "type": "boolean", + "optional": true + }, + "certificateTransparencyStatus": { + "description": "Certificate transparency compliance per RFC 6962. See https://www.certificate-transparency.org/what-is-ct for more information.", + "$ref": "CertificateTransparencyStatus", + "optional": true + }, + "hsts": { + "type": "boolean", + "description": "True if host uses Strict Transport Security and state is \"secure\".", + "optional": true + }, + "hpkp": { + "type": "string", + "description": "True if host uses Public Key Pinning and state is \"secure\".", + "optional": true + }, + "weaknessReasons": { + "type": "array", + "items": { "$ref": "TransportWeaknessReasons" }, + "description": "list of reasons that cause the request to be considered weak, if state is \"weak\"", + "optional": true + }, + "usedEch": { + "type": "boolean", + "description": "True if the TLS connection used Encrypted Client Hello.", + "optional": true + }, + "usedDelegatedCredentials": { + "type": "boolean", + "description": "True if the TLS connection used Delegated Credentials.", + "optional": true + }, + "usedOcsp": { + "type": "boolean", + "description": "True if the TLS connection made OCSP requests.", + "optional": true + }, + "usedPrivateDns": { + "type": "boolean", + "description": "True if the TLS connection used a privacy-preserving DNS transport like DNS-over-HTTPS.", + "optional": true + } + } + }, + { + "id": "UploadData", + "type": "object", + "properties": { + "bytes": { + "type": "any", + "optional": true, + "description": "An ArrayBuffer with a copy of the data." + }, + "file": { + "type": "string", + "optional": true, + "description": "A string with the file's path and name." + } + }, + "description": "Contains data uploaded in a URL request." + }, + { + "id": "UrlClassificationFlags", + "type": "string", + "enum": [ + "fingerprinting", + "fingerprinting_content", + "cryptomining", + "cryptomining_content", + "emailtracking", + "emailtracking_content", + "tracking", + "tracking_ad", + "tracking_analytics", + "tracking_social", + "tracking_content", + "any_basic_tracking", + "any_strict_tracking", + "any_social_tracking" + ], + "description": "Tracking flags that match our internal tracking classification" + }, + { + "id": "UrlClassificationParty", + "type": "array", + "items": { "$ref": "UrlClassificationFlags" }, + "description": "If the request has been classified this is an array of $(ref:UrlClassificationFlags)." + }, + { + "id": "UrlClassification", + "type": "object", + "properties": { + "firstParty": { + "$ref": "UrlClassificationParty", + "description": "Classification flags if the request has been classified and it is first party." + }, + "thirdParty": { + "$ref": "UrlClassificationParty", + "description": "Classification flags if the request has been classified and it or its window hierarchy is third party." + } + } + } + ], + "functions": [ + { + "name": "handlerBehaviorChanged", + "type": "function", + "description": "Needs to be called when the behavior of the webRequest handlers has changed to prevent incorrect handling due to caching. This function call is expensive. Don't call it often.", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "filterResponseData", + "permissions": ["webRequestBlocking"], + "type": "function", + "description": "...", + "parameters": [ + { + "name": "requestId", + "type": "string" + } + ], + "returns": { + "type": "object", + "additionalProperties": { "type": "any" }, + "isInstanceOf": "StreamFilter" + } + }, + { + "name": "getSecurityInfo", + "type": "function", + "async": true, + "description": "Retrieves the security information for the request. Returns a promise that will resolve to a SecurityInfo object.", + "parameters": [ + { + "name": "requestId", + "type": "string" + }, + { + "name": "options", + "optional": true, + "type": "object", + "properties": { + "certificateChain": { + "type": "boolean", + "description": "Include the entire certificate chain.", + "optional": true + }, + "rawDER": { + "type": "boolean", + "description": "Include raw certificate data for processing by the extension.", + "optional": true + } + } + } + ] + } + ], + "events": [ + { + "name": "onBeforeRequest", + "type": "function", + "description": "Fired when a request is about to occur.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": { + "type": "string", + "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request." + }, + "url": { "type": "string" }, + "method": { + "type": "string", + "description": "Standard HTTP method." + }, + "frameId": { + "type": "integer", + "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (type is main_frame or sub_frame), frameId indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists." + }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "True for private browsing requests." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "originUrl": { + "type": "string", + "optional": true, + "description": "URL of the resource that triggered this request." + }, + "documentUrl": { + "type": "string", + "optional": true, + "description": "URL of the page into which the requested resource will be loaded." + }, + "requestBody": { + "type": "object", + "optional": true, + "description": "Contains the HTTP request body data. Only provided if extraInfoSpec contains 'requestBody'.", + "properties": { + "error": { + "type": "string", + "optional": true, + "description": "Errors when obtaining request body data." + }, + "formData": { + "type": "object", + "optional": true, + "description": "If the request method is POST and the body is a sequence of key-value pairs encoded in UTF8, encoded as either multipart/form-data, or application/x-www-form-urlencoded, this dictionary is present and for each key contains the list of all values for that key. If the data is of another media type, or if it is malformed, the dictionary is not present. An example value of this dictionary is {'key': ['value1', 'value2']}.", + "properties": {}, + "additionalProperties": { + "type": "array", + "items": { "type": "string" } + } + }, + "raw": { + "type": "array", + "optional": true, + "items": { "$ref": "UploadData" }, + "description": "If the request method is PUT or POST, and the body is not already parsed in formData, then the unparsed request body elements are contained in this array." + } + } + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab." + }, + "type": { + "$ref": "ResourceType", + "description": "How the requested resource will be used." + }, + "timeStamp": { + "type": "number", + "description": "The time when this signal is triggered, in milliseconds since the epoch." + }, + "urlClassification": { + "$ref": "UrlClassification", + "optional": true, + "description": "Tracking classification if the request has been classified." + }, + "thirdParty": { + "type": "boolean", + "description": "Indicates if this request and its content window hierarchy is third party." + } + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnBeforeRequestOptions" + } + } + ], + "returns": { + "$ref": "BlockingResponse", + "description": "If \"blocking\" is specified in the \"extraInfoSpec\" parameter, the event listener should return an object of this type.", + "optional": true + } + }, + { + "name": "onBeforeSendHeaders", + "type": "function", + "description": "Fired before sending an HTTP request, once the request headers are available. This may occur after a TCP connection is made to the server, but before any HTTP data is sent. ", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": { + "type": "string", + "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request." + }, + "url": { "type": "string" }, + "method": { + "type": "string", + "description": "Standard HTTP method." + }, + "frameId": { + "type": "integer", + "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (type is main_frame or sub_frame), frameId indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists." + }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "True for private browsing requests." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "originUrl": { + "type": "string", + "optional": true, + "description": "URL of the resource that triggered this request." + }, + "documentUrl": { + "type": "string", + "optional": true, + "description": "URL of the page into which the requested resource will be loaded." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab." + }, + "type": { + "$ref": "ResourceType", + "description": "How the requested resource will be used." + }, + "timeStamp": { + "type": "number", + "description": "The time when this signal is triggered, in milliseconds since the epoch." + }, + "requestHeaders": { + "$ref": "HttpHeaders", + "optional": true, + "description": "The HTTP request headers that are going to be sent out with this request." + }, + "urlClassification": { + "$ref": "UrlClassification", + "optional": true, + "description": "Tracking classification if the request has been classified." + }, + "thirdParty": { + "type": "boolean", + "description": "Indicates if this request and its content window hierarchy is third party." + } + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnBeforeSendHeadersOptions" + } + } + ], + "returns": { + "$ref": "BlockingResponse", + "description": "If \"blocking\" is specified in the \"extraInfoSpec\" parameter, the event listener should return an object of this type.", + "optional": true + } + }, + { + "name": "onSendHeaders", + "type": "function", + "description": "Fired just before a request is going to be sent to the server (modifications of previous onBeforeSendHeaders callbacks are visible by the time onSendHeaders is fired).", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": { + "type": "string", + "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request." + }, + "url": { "type": "string" }, + "method": { + "type": "string", + "description": "Standard HTTP method." + }, + "frameId": { + "type": "integer", + "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (type is main_frame or sub_frame), frameId indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists." + }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "True for private browsing requests." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "originUrl": { + "type": "string", + "optional": true, + "description": "URL of the resource that triggered this request." + }, + "documentUrl": { + "type": "string", + "optional": true, + "description": "URL of the page into which the requested resource will be loaded." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab." + }, + "type": { + "$ref": "ResourceType", + "description": "How the requested resource will be used." + }, + "timeStamp": { + "type": "number", + "description": "The time when this signal is triggered, in milliseconds since the epoch." + }, + "requestHeaders": { + "$ref": "HttpHeaders", + "optional": true, + "description": "The HTTP request headers that have been sent out with this request." + }, + "urlClassification": { + "$ref": "UrlClassification", + "optional": true, + "description": "Tracking classification if the request has been classified." + }, + "thirdParty": { + "type": "boolean", + "description": "Indicates if this request and its content window hierarchy is third party." + } + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnSendHeadersOptions" + } + } + ] + }, + { + "name": "onHeadersReceived", + "type": "function", + "description": "Fired when HTTP response headers of a request have been received.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": { + "type": "string", + "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request." + }, + "url": { "type": "string" }, + "method": { + "type": "string", + "description": "Standard HTTP method." + }, + "frameId": { + "type": "integer", + "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (type is main_frame or sub_frame), frameId indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists." + }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "True for private browsing requests." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "originUrl": { + "type": "string", + "optional": true, + "description": "URL of the resource that triggered this request." + }, + "documentUrl": { + "type": "string", + "optional": true, + "description": "URL of the page into which the requested resource will be loaded." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab." + }, + "type": { + "$ref": "ResourceType", + "description": "How the requested resource will be used." + }, + "timeStamp": { + "type": "number", + "description": "The time when this signal is triggered, in milliseconds since the epoch." + }, + "statusLine": { + "type": "string", + "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line)." + }, + "responseHeaders": { + "$ref": "HttpHeaders", + "optional": true, + "description": "The HTTP response headers that have been received with this response." + }, + "statusCode": { + "type": "integer", + "description": "Standard HTTP status code returned by the server." + }, + "urlClassification": { + "$ref": "UrlClassification", + "optional": true, + "description": "Tracking classification if the request has been classified." + }, + "thirdParty": { + "type": "boolean", + "description": "Indicates if this request and its content window hierarchy is third party." + } + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnHeadersReceivedOptions" + } + } + ], + "returns": { + "$ref": "BlockingResponse", + "description": "If \"blocking\" is specified in the \"extraInfoSpec\" parameter, the event listener should return an object of this type.", + "optional": true + } + }, + { + "name": "onAuthRequired", + "type": "function", + "description": "Fired when an authentication failure is received. The listener has three options: it can provide authentication credentials, it can cancel the request and display the error page, or it can take no action on the challenge. If bad user credentials are provided, this may be called multiple times for the same request.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": { + "type": "string", + "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request." + }, + "url": { "type": "string" }, + "method": { + "type": "string", + "description": "Standard HTTP method." + }, + "frameId": { + "type": "integer", + "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (type is main_frame or sub_frame), frameId indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists." + }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "True for private browsing requests." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "originUrl": { + "type": "string", + "optional": true, + "description": "URL of the resource that triggered this request." + }, + "documentUrl": { + "type": "string", + "optional": true, + "description": "URL of the page into which the requested resource will be loaded." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab." + }, + "type": { + "$ref": "ResourceType", + "description": "How the requested resource will be used." + }, + "timeStamp": { + "type": "number", + "description": "The time when this signal is triggered, in milliseconds since the epoch." + }, + "scheme": { + "type": "string", + "description": "The authentication scheme, e.g. Basic or Digest." + }, + "realm": { + "type": "string", + "description": "The authentication realm provided by the server, if there is one.", + "optional": true + }, + "challenger": { + "type": "object", + "description": "The server requesting authentication.", + "properties": { + "host": { "type": "string" }, + "port": { "type": "integer" } + } + }, + "isProxy": { + "type": "boolean", + "description": "True for Proxy-Authenticate, false for WWW-Authenticate." + }, + "responseHeaders": { + "$ref": "HttpHeaders", + "optional": true, + "description": "The HTTP response headers that were received along with this response." + }, + "statusLine": { + "type": "string", + "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line) or an empty string if there are no headers." + }, + "statusCode": { + "type": "integer", + "description": "Standard HTTP status code returned by the server." + }, + "urlClassification": { + "$ref": "UrlClassification", + "optional": true, + "description": "Tracking classification if the request has been classified." + }, + "thirdParty": { + "type": "boolean", + "description": "Indicates if this request and its content window hierarchy is third party." + } + } + }, + { + "type": "function", + "optional": true, + "name": "callback", + "parameters": [{ "name": "response", "$ref": "BlockingResponse" }] + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnAuthRequiredOptions" + } + } + ], + "returns": { + "$ref": "BlockingResponse", + "description": "If \"blocking\" is specified in the \"extraInfoSpec\" parameter, the event listener should return an object of this type.", + "optional": true + } + }, + { + "name": "onResponseStarted", + "type": "function", + "description": "Fired when the first byte of the response body is received. For HTTP requests, this means that the status line and response headers are available.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": { + "type": "string", + "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request." + }, + "url": { "type": "string" }, + "method": { + "type": "string", + "description": "Standard HTTP method." + }, + "frameId": { + "type": "integer", + "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (type is main_frame or sub_frame), frameId indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists." + }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "True for private browsing requests." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "originUrl": { + "type": "string", + "optional": true, + "description": "URL of the resource that triggered this request." + }, + "documentUrl": { + "type": "string", + "optional": true, + "description": "URL of the page into which the requested resource will be loaded." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab." + }, + "type": { + "$ref": "ResourceType", + "description": "How the requested resource will be used." + }, + "timeStamp": { + "type": "number", + "description": "The time when this signal is triggered, in milliseconds since the epoch." + }, + "ip": { + "type": "string", + "optional": true, + "description": "The server IP address that the request was actually sent to. Note that it may be a literal IPv6 address." + }, + "fromCache": { + "type": "boolean", + "description": "Indicates if this response was fetched from disk cache." + }, + "statusCode": { + "type": "integer", + "description": "Standard HTTP status code returned by the server." + }, + "responseHeaders": { + "$ref": "HttpHeaders", + "optional": true, + "description": "The HTTP response headers that were received along with this response." + }, + "statusLine": { + "type": "string", + "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line) or an empty string if there are no headers." + }, + "urlClassification": { + "$ref": "UrlClassification", + "optional": true, + "description": "Tracking classification if the request has been classified." + }, + "thirdParty": { + "type": "boolean", + "description": "Indicates if this request and its content window hierarchy is third party." + } + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnResponseStartedOptions" + } + } + ] + }, + { + "name": "onBeforeRedirect", + "type": "function", + "description": "Fired when a server-initiated redirect is about to occur.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": { + "type": "string", + "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request." + }, + "url": { "type": "string" }, + "method": { + "type": "string", + "description": "Standard HTTP method." + }, + "frameId": { + "type": "integer", + "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (type is main_frame or sub_frame), frameId indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists." + }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "True for private browsing requests." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "originUrl": { + "type": "string", + "optional": true, + "description": "URL of the resource that triggered this request." + }, + "documentUrl": { + "type": "string", + "optional": true, + "description": "URL of the page into which the requested resource will be loaded." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab." + }, + "type": { + "$ref": "ResourceType", + "description": "How the requested resource will be used." + }, + "timeStamp": { + "type": "number", + "description": "The time when this signal is triggered, in milliseconds since the epoch." + }, + "ip": { + "type": "string", + "optional": true, + "description": "The server IP address that the request was actually sent to. Note that it may be a literal IPv6 address." + }, + "fromCache": { + "type": "boolean", + "description": "Indicates if this response was fetched from disk cache." + }, + "statusCode": { + "type": "integer", + "description": "Standard HTTP status code returned by the server." + }, + "redirectUrl": { + "type": "string", + "description": "The new URL." + }, + "responseHeaders": { + "$ref": "HttpHeaders", + "optional": true, + "description": "The HTTP response headers that were received along with this redirect." + }, + "statusLine": { + "type": "string", + "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line) or an empty string if there are no headers." + }, + "urlClassification": { + "$ref": "UrlClassification", + "optional": true, + "description": "Tracking classification if the request has been classified." + }, + "thirdParty": { + "type": "boolean", + "description": "Indicates if this request and its content window hierarchy is third party." + } + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnBeforeRedirectOptions" + } + } + ] + }, + { + "name": "onCompleted", + "type": "function", + "description": "Fired when a request is completed.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": { + "type": "string", + "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request." + }, + "url": { "type": "string" }, + "method": { + "type": "string", + "description": "Standard HTTP method." + }, + "frameId": { + "type": "integer", + "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (type is main_frame or sub_frame), frameId indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists." + }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "True for private browsing requests." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "originUrl": { + "type": "string", + "optional": true, + "description": "URL of the resource that triggered this request." + }, + "documentUrl": { + "type": "string", + "optional": true, + "description": "URL of the page into which the requested resource will be loaded." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab." + }, + "type": { + "$ref": "ResourceType", + "description": "How the requested resource will be used." + }, + "timeStamp": { + "type": "number", + "description": "The time when this signal is triggered, in milliseconds since the epoch." + }, + "ip": { + "type": "string", + "optional": true, + "description": "The server IP address that the request was actually sent to. Note that it may be a literal IPv6 address." + }, + "fromCache": { + "type": "boolean", + "description": "Indicates if this response was fetched from disk cache." + }, + "statusCode": { + "type": "integer", + "description": "Standard HTTP status code returned by the server." + }, + "responseHeaders": { + "$ref": "HttpHeaders", + "optional": true, + "description": "The HTTP response headers that were received along with this response." + }, + "statusLine": { + "type": "string", + "description": "HTTP status line of the response or the 'HTTP/0.9 200 OK' string for HTTP/0.9 responses (i.e., responses that lack a status line) or an empty string if there are no headers." + }, + "urlClassification": { + "$ref": "UrlClassification", + "description": "Tracking classification if the request has been classified." + }, + "thirdParty": { + "type": "boolean", + "description": "Indicates if this request and its content window hierarchy is third party." + }, + "requestSize": { + "type": "integer", + "description": "For http requests, the bytes transferred in the request. Only available in onCompleted." + }, + "responseSize": { + "type": "integer", + "description": "For http requests, the bytes received in the request. Only available in onCompleted." + } + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + }, + { + "type": "array", + "optional": true, + "name": "extraInfoSpec", + "description": "Array of extra information that should be passed to the listener function.", + "items": { + "$ref": "OnCompletedOptions" + } + } + ] + }, + { + "name": "onErrorOccurred", + "type": "function", + "description": "Fired when an error occurs.", + "parameters": [ + { + "type": "object", + "name": "details", + "properties": { + "requestId": { + "type": "string", + "description": "The ID of the request. Request IDs are unique within a browser session. As a result, they could be used to relate different events of the same request." + }, + "url": { "type": "string" }, + "method": { + "type": "string", + "description": "Standard HTTP method." + }, + "frameId": { + "type": "integer", + "description": "The value 0 indicates that the request happens in the main frame; a positive value indicates the ID of a subframe in which the request happens. If the document of a (sub-)frame is loaded (type is main_frame or sub_frame), frameId indicates the ID of this frame, not the ID of the outer frame. Frame IDs are unique within a tab." + }, + "parentFrameId": { + "type": "integer", + "description": "ID of frame that wraps the frame which sent the request. Set to -1 if no parent frame exists." + }, + "incognito": { + "type": "boolean", + "optional": true, + "description": "True for private browsing requests." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The cookie store ID of the contextual identity." + }, + "originUrl": { + "type": "string", + "optional": true, + "description": "URL of the resource that triggered this request." + }, + "documentUrl": { + "type": "string", + "optional": true, + "description": "URL of the page into which the requested resource will be loaded." + }, + "tabId": { + "type": "integer", + "description": "The ID of the tab in which the request takes place. Set to -1 if the request isn't related to a tab." + }, + "type": { + "$ref": "ResourceType", + "description": "How the requested resource will be used." + }, + "timeStamp": { + "type": "number", + "description": "The time when this signal is triggered, in milliseconds since the epoch." + }, + "ip": { + "type": "string", + "optional": true, + "description": "The server IP address that the request was actually sent to. Note that it may be a literal IPv6 address." + }, + "fromCache": { + "type": "boolean", + "description": "Indicates if this response was fetched from disk cache." + }, + "error": { + "type": "string", + "description": "The error description. This string is not guaranteed to remain backwards compatible between releases. You must not parse and act based upon its content." + }, + "urlClassification": { + "$ref": "UrlClassification", + "optional": true, + "description": "Tracking classification if the request has been classified." + }, + "thirdParty": { + "type": "boolean", + "description": "Indicates if this request and its content window hierarchy is third party." + } + } + } + ], + "extraParameters": [ + { + "$ref": "RequestFilter", + "name": "filter", + "description": "A set of filters that restricts the events that will be sent to this listener." + } + ] + } + ] + } +] diff --git a/toolkit/components/extensions/storage/ExtensionStorageComponents.h b/toolkit/components/extensions/storage/ExtensionStorageComponents.h new file mode 100644 index 0000000000..53af177432 --- /dev/null +++ b/toolkit/components/extensions/storage/ExtensionStorageComponents.h @@ -0,0 +1,40 @@ +/* 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/. */ + +#ifndef mozilla_extensions_storage_ExtensionStorageComponents_h_ +#define mozilla_extensions_storage_ExtensionStorageComponents_h_ + +#include "mozIExtensionStorageArea.h" +#include "nsCOMPtr.h" + +extern "C" { + +// Implemented in Rust, in the `webext_storage_bridge` crate. +nsresult NS_NewExtensionStorageSyncArea(mozIExtensionStorageArea** aResult); + +} // extern "C" + +namespace mozilla { +namespace extensions { +namespace storage { + +// The C++ constructor for a `storage.sync` area. This wrapper exists because +// `components.conf` requires a component class constructor to return an +// `already_AddRefed`, but Rust doesn't have such a type. So we call the +// Rust constructor using a `nsCOMPtr` (which is compatible with Rust's +// `xpcom::RefPtr`) out param, and return that. +already_AddRefed NewSyncArea() { + nsCOMPtr storage; + nsresult rv = NS_NewExtensionStorageSyncArea(getter_AddRefs(storage)); + if (NS_WARN_IF(NS_FAILED(rv))) { + return nullptr; + } + return storage.forget(); +} + +} // namespace storage +} // namespace extensions +} // namespace mozilla + +#endif // mozilla_extensions_storage_ExtensionStorageComponents_h_ diff --git a/toolkit/components/extensions/storage/ExtensionStorageComponents.sys.mjs b/toolkit/components/extensions/storage/ExtensionStorageComponents.sys.mjs new file mode 100644 index 0000000000..4de62005a1 --- /dev/null +++ b/toolkit/components/extensions/storage/ExtensionStorageComponents.sys.mjs @@ -0,0 +1,118 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); + +const StorageSyncArea = Components.Constructor( + "@mozilla.org/extensions/storage/internal/sync-area;1", + "mozIConfigurableExtensionStorageArea", + "configure" +); + +/** + * An XPCOM service for the WebExtension `storage.sync` API. The service manages + * a storage area for storing and syncing extension data. + * + * The service configures its storage area with the database path, and hands + * out references to the configured area via `getInterface`. It also registers + * a shutdown blocker to automatically tear down the area. + * + * ## What's the difference between `storage/internal/storage-sync-area;1` and + * `storage/sync;1`? + * + * `components.conf` has two classes: + * `@mozilla.org/extensions/storage/internal/sync-area;1` and + * `@mozilla.org/extensions/storage/sync;1`. + * + * The `storage/internal/sync-area;1` class is implemented in Rust, and can be + * instantiated using `createInstance` and `Components.Constructor`. It's not + * a singleton, so creating a new instance will create a new `storage.sync` + * area, with its own database connection. It's useful for testing, but not + * meant to be used outside of this module. + * + * The `storage/sync;1` class is implemented in this file. It's a singleton, + * ensuring there's only one `storage.sync` area, with one database connection. + * The service implements `nsIInterfaceRequestor`, so callers can access the + * storage interface like this: + * + * let storageSyncArea = Cc["@mozilla.org/extensions/storage/sync;1"] + * .getService(Ci.nsIInterfaceRequestor) + * .getInterface(Ci.mozIExtensionStorageArea); + * + * ...And the Sync interface like this: + * + * let extensionStorageEngine = Cc["@mozilla.org/extensions/storage/sync;1"] + * .getService(Ci.nsIInterfaceRequestor) + * .getInterface(Ci.mozIBridgedSyncEngine); + * + * @class + */ +export function StorageSyncService() { + if (StorageSyncService._singleton) { + return StorageSyncService._singleton; + } + + let file = new lazy.FileUtils.File( + PathUtils.join(PathUtils.profileDir, "storage-sync-v2.sqlite") + ); + let kintoFile = new lazy.FileUtils.File( + PathUtils.join(PathUtils.profileDir, "storage-sync.sqlite") + ); + this._storageArea = new StorageSyncArea(file, kintoFile); + + // Register a blocker to close the storage connection on shutdown. + this._shutdownBound = () => this._shutdown(); + lazy.AsyncShutdown.profileChangeTeardown.addBlocker( + "StorageSyncService: shutdown", + this._shutdownBound + ); + + StorageSyncService._singleton = this; +} + +StorageSyncService._singleton = null; + +StorageSyncService.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsIInterfaceRequestor"]), + + // Returns the storage and syncing interfaces. This just hands out a + // reference to the underlying storage area, with a quick check to make sure + // that callers are asking for the right interfaces. + getInterface(iid) { + if ( + iid.equals(Ci.mozIExtensionStorageArea) || + iid.equals(Ci.mozIBridgedSyncEngine) + ) { + return this._storageArea.QueryInterface(iid); + } + throw Components.Exception( + "This interface isn't implemented", + Cr.NS_ERROR_NO_INTERFACE + ); + }, + + // Tears down the storage area and lifts the blocker so that shutdown can + // continue. + async _shutdown() { + try { + await new Promise((resolve, reject) => { + this._storageArea.teardown({ + handleSuccess: resolve, + handleError(code, message) { + reject(Components.Exception(message, code)); + }, + }); + }); + } finally { + lazy.AsyncShutdown.profileChangeTeardown.removeBlocker( + this._shutdownBound + ); + } + }, +}; diff --git a/toolkit/components/extensions/storage/components.conf b/toolkit/components/extensions/storage/components.conf new file mode 100644 index 0000000000..a1d54fa542 --- /dev/null +++ b/toolkit/components/extensions/storage/components.conf @@ -0,0 +1,22 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +Classes = [ + { + 'cid': '{f1e424f2-67fe-4f69-a8f8-3993a71f44fa}', + 'contract_ids': ['@mozilla.org/extensions/storage/internal/sync-area;1'], + 'type': 'mozIExtensionStorageArea', + 'headers': ['mozilla/extensions/storage/ExtensionStorageComponents.h'], + 'constructor': 'mozilla::extensions::storage::NewSyncArea', + }, + { + 'cid': '{5b7047b4-fe17-4661-8e13-871402bc2023}', + 'contract_ids': ['@mozilla.org/extensions/storage/sync;1'], + 'esModule': 'resource://gre/modules/ExtensionStorageComponents.sys.mjs', + 'constructor': 'StorageSyncService', + 'singleton': True, + }, +] diff --git a/toolkit/components/extensions/storage/moz.build b/toolkit/components/extensions/storage/moz.build new file mode 100644 index 0000000000..85f52cdadb --- /dev/null +++ b/toolkit/components/extensions/storage/moz.build @@ -0,0 +1,33 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +with Files("**"): + BUG_COMPONENT = ("WebExtensions", "Storage") + +XPIDL_MODULE = "webextensions-storage" + +XPIDL_SOURCES += [ + "mozIExtensionStorageArea.idl", +] + +# Don't build the Rust `storage.sync` bridge for GeckoView, as it will expose +# a delegate for consumers to use instead. Android Components can then provide +# an implementation of the delegate that's backed by the Rust component. For +# details, please see bug 1626506, comment 4. +if CONFIG["MOZ_WIDGET_TOOLKIT"] != "android": + EXPORTS.mozilla.extensions.storage += [ + "ExtensionStorageComponents.h", + ] + + EXTRA_JS_MODULES += [ + "ExtensionStorageComponents.sys.mjs", + ] + + XPCOM_MANIFESTS += [ + "components.conf", + ] + +FINAL_LIBRARY = "xul" diff --git a/toolkit/components/extensions/storage/mozIExtensionStorageArea.idl b/toolkit/components/extensions/storage/mozIExtensionStorageArea.idl new file mode 100644 index 0000000000..b3dcaa2479 --- /dev/null +++ b/toolkit/components/extensions/storage/mozIExtensionStorageArea.idl @@ -0,0 +1,127 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface mozIExtensionStorageCallback; +interface nsIFile; +interface nsIVariant; + +// Implements the operations needed to support the `StorageArea` WebExtension +// API. +[scriptable, uuid(d8eb3ff1-9b4b-435a-99ca-5b8cbaba2420)] +interface mozIExtensionStorageArea : nsISupports { + // These constants are exposed by the rust crate, but it's not worth the + // effort of jumping through the hoops to get them exposed to the JS + // code in a sane way - so we just duplicate them here. We should consider a + // test that checks they match the rust code. + // This interface is agnostic WRT the area, so we prefix the constants with + // the area - it's the consumer of this interface which knows what to use. + const unsigned long SYNC_QUOTA_BYTES = 102400; + const unsigned long SYNC_QUOTA_BYTES_PER_ITEM = 8192; + const unsigned long SYNC_MAX_ITEMS = 512; + + // Sets one or more key-value pairs specified in `json` for the + // `extensionId`. If the `callback` implements + // `mozIExtensionStorageListener`, its `onChange` + // method will be called with the new and old values. + void set(in AUTF8String extensionId, + in AUTF8String json, + in mozIExtensionStorageCallback callback); + + // Returns the value for the `key` in the storage area for the + // `extensionId`. `key` must be a JSON string containing either `null`, + // an array of string key names, a single string key name, or an object + // where the properties are the key names, and the values are the defaults + // if the key name doesn't exist in the storage area. + // + // If `get()` fails due to the quota being exceeded, the exception will + // have a result code of NS_ERROR_DOM_QUOTA_EXCEEDED_ERR (==0x80530016) + void get(in AUTF8String extensionId, + in AUTF8String key, + in mozIExtensionStorageCallback callback); + + // Removes the `key` from the storage area for the `extensionId`. If `key` + // exists and the `callback` implements `mozIExtensionStorageListener`, its + // `onChanged` method will be called with the removed key-value pair. + void remove(in AUTF8String extensionId, + in AUTF8String key, + in mozIExtensionStorageCallback callback); + + // Removes all keys from the storage area for the `extensionId`. If + // `callback` implements `mozIExtensionStorageListener`, its `onChange` + // method will be called with all removed key-value pairs. + void clear(in AUTF8String extensionId, + in mozIExtensionStorageCallback callback); + + // Gets the number of bytes in use for the specified keys. + void getBytesInUse(in AUTF8String extensionId, + in AUTF8String keys, + in mozIExtensionStorageCallback callback); + + // Gets and clears the information about the migration from the kinto + // database into the rust one. As "and clears" indicates, this will + // only produce a non-empty the first time it's called after a + // migration (which, hopefully, should only happen once). + void takeMigrationInfo(in mozIExtensionStorageCallback callback); +}; + +// Implements additional methods for setting up and tearing down the underlying +// database connection for a storage area. This is a separate interface because +// these methods are not part of the `StorageArea` API, and have restrictions on +// when they can be called. +[scriptable, uuid(2b008295-1bcc-4610-84f1-ad4cab2fa9ee)] +interface mozIConfigurableExtensionStorageArea : nsISupports { + // Sets up the storage area. An area can only be configured once; calling + // `configure` multiple times will throw. `configure` must also be called + // before any of the `mozIExtensionStorageArea` methods, or they'll fail + // with errors. + // The second param is the path to the kinto database file from which we + // should migrate. This should always be specified even when there's a + // chance the file doesn't exist. + void configure(in nsIFile databaseFile, in nsIFile kintoFile); + + // Tears down the storage area, closing the backing database connection. + // This is called automatically when Firefox shuts down. Once a storage area + // has been shut down, all its methods will fail with errors. If `configure` + // hasn't been called for this area yet, `teardown` is a no-op. + void teardown(in mozIExtensionStorageCallback callback); +}; + +// Implements additional methods for syncing a storage area. This is a separate +// interface because these methods are not part of the `StorageArea` API, and +// have restrictions on when they can be called. +[scriptable, uuid(6dac82c9-1d8a-4893-8c0f-6e626aef802c)] +interface mozISyncedExtensionStorageArea : nsISupports { + // If a sync is in progress, this method fetches pending change + // notifications for all extensions whose storage areas were updated. + // `callback` should implement `mozIExtensionStorageListener` to forward + // the records to `storage.onChanged` listeners. This method should only + // be called by Sync, after `mozIBridgedSyncEngine.apply` and before + // `syncFinished`. It fetches nothing if called at any other time. + void fetchPendingSyncChanges(in mozIExtensionStorageCallback callback); +}; + +// A listener for storage area notifications. +[scriptable, uuid(8cb3c7e4-d0ca-4353-bccd-2673b4e11510)] +interface mozIExtensionStorageListener : nsISupports { + // Notifies that an operation has data to pass to `storage.onChanged` + // listeners for the given `extensionId`. `json` is a JSON array of listener + // infos. If an operation affects multiple extensions, this method will be + // called multiple times, once per extension. + void onChanged(in AUTF8String extensionId, in AUTF8String json); +}; + +// A generic callback for a storage operation. Either `handleSuccess` or +// `handleError` is guaranteed to be called once. +[scriptable, uuid(870dca40-6602-4748-8493-c4253eb7f322)] +interface mozIExtensionStorageCallback : nsISupports { + // Called when the operation completes. Operations that return a result, + // like `get`, will pass a `UTF8String` variant. Those that don't return + // anything, like `set` or `remove`, will pass a `null` variant. + void handleSuccess(in nsIVariant result); + + // Called when the operation fails. + void handleError(in nsresult code, in AUTF8String message); +}; diff --git a/toolkit/components/extensions/storage/webext_storage_bridge/Cargo.toml b/toolkit/components/extensions/storage/webext_storage_bridge/Cargo.toml new file mode 100644 index 0000000000..39c5bf92c6 --- /dev/null +++ b/toolkit/components/extensions/storage/webext_storage_bridge/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "webext_storage_bridge" +description = "The WebExtension `storage.sync` bindings for Firefox" +version = "0.1.0" +authors = ["The Firefox Sync Developers "] +edition = "2018" +license = "MPL-2.0" + +[dependencies] +anyhow = "1.0" +atomic_refcell = "0.1" +cstr = "0.2" +golden_gate = { path = "../../../../../services/sync/golden_gate" } +interrupt-support = "0.1" +moz_task = { path = "../../../../../xpcom/rust/moz_task" } +nserror = { path = "../../../../../xpcom/rust/nserror" } +nsstring = { path = "../../../../../xpcom/rust/nsstring" } +once_cell = "1" +thin-vec = { version = "0.2.1", features = ["gecko-ffi"] } +xpcom = { path = "../../../../../xpcom/rust/xpcom" } +serde = "1" +serde_json = "1" +storage_variant = { path = "../../../../../storage/variant" } +sql-support = "0.1" +webext-storage = "0.1" diff --git a/toolkit/components/extensions/storage/webext_storage_bridge/src/area.rs b/toolkit/components/extensions/storage/webext_storage_bridge/src/area.rs new file mode 100644 index 0000000000..1418ccca29 --- /dev/null +++ b/toolkit/components/extensions/storage/webext_storage_bridge/src/area.rs @@ -0,0 +1,484 @@ +/* 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 std::{ + cell::{Ref, RefCell}, + convert::TryInto, + ffi::OsString, + mem, + path::PathBuf, + str, + sync::Arc, +}; + +use golden_gate::{ApplyTask, BridgedEngine, FerryTask}; +use moz_task::{self, DispatchOptions, TaskRunnable}; +use nserror::{nsresult, NS_OK}; +use nsstring::{nsACString, nsCString, nsString}; +use thin_vec::ThinVec; +use webext_storage::STORAGE_VERSION; +use xpcom::{ + interfaces::{ + mozIBridgedSyncEngineApplyCallback, mozIBridgedSyncEngineCallback, + mozIExtensionStorageCallback, mozIServicesLogSink, nsIFile, nsISerialEventTarget, + }, + RefPtr, +}; + +use crate::error::{Error, Result}; +use crate::punt::{Punt, PuntTask, TeardownTask}; +use crate::store::{LazyStore, LazyStoreConfig}; + +fn path_from_nsifile(file: &nsIFile) -> Result { + let mut raw_path = nsString::new(); + // `nsIFile::GetPath` gives us a UTF-16-encoded version of its + // native path, which we must turn back into a platform-native + // string. We can't use `nsIFile::nativePath()` here because + // it's marked as `nostdcall`, which Rust doesn't support. + unsafe { file.GetPath(&mut *raw_path) }.to_result()?; + let native_path = { + // On Windows, we can create a native string directly from the + // encoded path. + #[cfg(windows)] + { + use std::os::windows::prelude::*; + OsString::from_wide(&raw_path) + } + // On other platforms, we must first decode the raw path from + // UTF-16, and then create our native string. + #[cfg(not(windows))] + OsString::from(String::from_utf16(&raw_path)?) + }; + Ok(native_path.into()) +} + +/// An XPCOM component class for the Rust extension storage API. This class +/// implements the interfaces needed for syncing and storage. +/// +/// This class can be created on any thread, but must not be shared between +/// threads. In Rust terms, it's `Send`, but not `Sync`. +#[xpcom( + implement( + mozIExtensionStorageArea, + mozIConfigurableExtensionStorageArea, + mozISyncedExtensionStorageArea, + mozIInterruptible, + mozIBridgedSyncEngine + ), + nonatomic +)] +pub struct StorageSyncArea { + /// A background task queue, used to run all our storage operations on a + /// thread pool. Using a serial event target here means that all operations + /// will execute sequentially. + queue: RefPtr, + /// The store is lazily initialized on the task queue the first time it's + /// used. + store: RefCell>>, +} + +/// `mozIExtensionStorageArea` implementation. +impl StorageSyncArea { + /// Creates a storage area and its task queue. + pub fn new() -> Result> { + let queue = moz_task::create_background_task_queue(cstr!("StorageSyncArea"))?; + Ok(StorageSyncArea::allocate(InitStorageSyncArea { + queue, + store: RefCell::new(Some(Arc::default())), + })) + } + + /// Returns the store for this area, or an error if it's been torn down. + fn store(&self) -> Result>> { + let maybe_store = self.store.borrow(); + if maybe_store.is_some() { + Ok(Ref::map(maybe_store, |s| s.as_ref().unwrap())) + } else { + Err(Error::AlreadyTornDown) + } + } + + /// Dispatches a task for a storage operation to the task queue. + fn dispatch(&self, punt: Punt, callback: &mozIExtensionStorageCallback) -> Result<()> { + let name = punt.name(); + let task = PuntTask::new(Arc::downgrade(&*self.store()?), punt, callback)?; + let runnable = TaskRunnable::new(name, Box::new(task))?; + // `may_block` schedules the runnable on a dedicated I/O pool. + TaskRunnable::dispatch_with_options( + runnable, + self.queue.coerce(), + DispatchOptions::new().may_block(true), + )?; + Ok(()) + } + + xpcom_method!( + configure => Configure( + database_file: *const nsIFile, + kinto_file: *const nsIFile + ) + ); + /// Sets up the storage area. + fn configure(&self, database_file: &nsIFile, kinto_file: &nsIFile) -> Result<()> { + self.store()?.configure(LazyStoreConfig { + path: path_from_nsifile(database_file)?, + kinto_path: path_from_nsifile(kinto_file)?, + })?; + Ok(()) + } + + xpcom_method!( + set => Set( + ext_id: *const ::nsstring::nsACString, + json: *const ::nsstring::nsACString, + callback: *const mozIExtensionStorageCallback + ) + ); + /// Sets one or more key-value pairs. + fn set( + &self, + ext_id: &nsACString, + json: &nsACString, + callback: &mozIExtensionStorageCallback, + ) -> Result<()> { + self.dispatch( + Punt::Set { + ext_id: str::from_utf8(ext_id)?.into(), + value: serde_json::from_str(str::from_utf8(json)?)?, + }, + callback, + )?; + Ok(()) + } + + xpcom_method!( + get => Get( + ext_id: *const ::nsstring::nsACString, + json: *const ::nsstring::nsACString, + callback: *const mozIExtensionStorageCallback + ) + ); + /// Gets values for one or more keys. + fn get( + &self, + ext_id: &nsACString, + json: &nsACString, + callback: &mozIExtensionStorageCallback, + ) -> Result<()> { + self.dispatch( + Punt::Get { + ext_id: str::from_utf8(ext_id)?.into(), + keys: serde_json::from_str(str::from_utf8(json)?)?, + }, + callback, + ) + } + + xpcom_method!( + remove => Remove( + ext_id: *const ::nsstring::nsACString, + json: *const ::nsstring::nsACString, + callback: *const mozIExtensionStorageCallback + ) + ); + /// Removes one or more keys and their values. + fn remove( + &self, + ext_id: &nsACString, + json: &nsACString, + callback: &mozIExtensionStorageCallback, + ) -> Result<()> { + self.dispatch( + Punt::Remove { + ext_id: str::from_utf8(ext_id)?.into(), + keys: serde_json::from_str(str::from_utf8(json)?)?, + }, + callback, + ) + } + + xpcom_method!( + clear => Clear( + ext_id: *const ::nsstring::nsACString, + callback: *const mozIExtensionStorageCallback + ) + ); + /// Removes all keys and values for the specified extension. + fn clear(&self, ext_id: &nsACString, callback: &mozIExtensionStorageCallback) -> Result<()> { + self.dispatch( + Punt::Clear { + ext_id: str::from_utf8(ext_id)?.into(), + }, + callback, + ) + } + + xpcom_method!( + getBytesInUse => GetBytesInUse( + ext_id: *const ::nsstring::nsACString, + keys: *const ::nsstring::nsACString, + callback: *const mozIExtensionStorageCallback + ) + ); + /// Obtains the count of bytes in use for the specified key or for all keys. + fn getBytesInUse( + &self, + ext_id: &nsACString, + keys: &nsACString, + callback: &mozIExtensionStorageCallback, + ) -> Result<()> { + self.dispatch( + Punt::GetBytesInUse { + ext_id: str::from_utf8(ext_id)?.into(), + keys: serde_json::from_str(str::from_utf8(keys)?)?, + }, + callback, + ) + } + + xpcom_method!(teardown => Teardown(callback: *const mozIExtensionStorageCallback)); + /// Tears down the storage area, closing the backing database connection. + fn teardown(&self, callback: &mozIExtensionStorageCallback) -> Result<()> { + // Each storage task holds a `Weak` reference to the store, which it + // upgrades to an `Arc` (strong reference) when the task runs on the + // background queue. The strong reference is dropped when the task + // finishes. When we tear down the storage area, we relinquish our one + // owned strong reference to the `TeardownTask`. Because we're using a + // task queue, when the `TeardownTask` runs, it should have the only + // strong reference to the store, since all other tasks that called + // `Weak::upgrade` will have already finished. The `TeardownTask` can + // then consume the `Arc` and destroy the store. + let mut maybe_store = self.store.borrow_mut(); + match mem::take(&mut *maybe_store) { + Some(store) => { + // Interrupt any currently-running statements. + store.interrupt(); + // If dispatching the runnable fails, we'll leak the store + // without closing its database connection. + teardown(&self.queue, store, callback)?; + } + None => return Err(Error::AlreadyTornDown), + } + Ok(()) + } + + xpcom_method!(takeMigrationInfo => TakeMigrationInfo(callback: *const mozIExtensionStorageCallback)); + + /// Fetch-and-delete (e.g. `take`) information about the migration from the + /// kinto-based extension-storage to the rust-based storage. + fn takeMigrationInfo(&self, callback: &mozIExtensionStorageCallback) -> Result<()> { + self.dispatch(Punt::TakeMigrationInfo, callback) + } +} + +fn teardown( + queue: &nsISerialEventTarget, + store: Arc, + callback: &mozIExtensionStorageCallback, +) -> Result<()> { + let task = TeardownTask::new(store, callback)?; + let runnable = TaskRunnable::new(TeardownTask::name(), Box::new(task))?; + TaskRunnable::dispatch_with_options( + runnable, + queue.coerce(), + DispatchOptions::new().may_block(true), + )?; + Ok(()) +} + +/// `mozISyncedExtensionStorageArea` implementation. +impl StorageSyncArea { + xpcom_method!( + fetch_pending_sync_changes => FetchPendingSyncChanges(callback: *const mozIExtensionStorageCallback) + ); + fn fetch_pending_sync_changes(&self, callback: &mozIExtensionStorageCallback) -> Result<()> { + self.dispatch(Punt::FetchPendingSyncChanges, callback) + } +} + +/// `mozIInterruptible` implementation. +impl StorageSyncArea { + xpcom_method!( + interrupt => Interrupt() + ); + /// Interrupts any operations currently running on the background task + /// queue. + fn interrupt(&self) -> Result<()> { + self.store()?.interrupt(); + Ok(()) + } +} + +/// `mozIBridgedSyncEngine` implementation. +impl StorageSyncArea { + xpcom_method!(get_logger => GetLogger() -> *const mozIServicesLogSink); + fn get_logger(&self) -> Result> { + Err(NS_OK)? + } + + xpcom_method!(set_logger => SetLogger(logger: *const mozIServicesLogSink)); + fn set_logger(&self, _logger: Option<&mozIServicesLogSink>) -> Result<()> { + Ok(()) + } + + xpcom_method!(get_storage_version => GetStorageVersion() -> i32); + fn get_storage_version(&self) -> Result { + Ok(STORAGE_VERSION.try_into().unwrap()) + } + + // It's possible that migration, or even merging, will result in records + // too large for the server. We tolerate that (and hope that the addons do + // too :) + xpcom_method!(get_allow_skipped_record => GetAllowSkippedRecord() -> bool); + fn get_allow_skipped_record(&self) -> Result { + Ok(true) + } + + xpcom_method!( + get_last_sync => GetLastSync( + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn get_last_sync(&self, callback: &mozIBridgedSyncEngineCallback) -> Result<()> { + Ok(FerryTask::for_last_sync(self.new_bridge()?, callback)?.dispatch(&self.queue)?) + } + + xpcom_method!( + set_last_sync => SetLastSync( + last_sync_millis: i64, + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn set_last_sync( + &self, + last_sync_millis: i64, + callback: &mozIBridgedSyncEngineCallback, + ) -> Result<()> { + Ok( + FerryTask::for_set_last_sync(self.new_bridge()?, last_sync_millis, callback)? + .dispatch(&self.queue)?, + ) + } + + xpcom_method!( + get_sync_id => GetSyncId( + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn get_sync_id(&self, callback: &mozIBridgedSyncEngineCallback) -> Result<()> { + Ok(FerryTask::for_sync_id(self.new_bridge()?, callback)?.dispatch(&self.queue)?) + } + + xpcom_method!( + reset_sync_id => ResetSyncId( + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn reset_sync_id(&self, callback: &mozIBridgedSyncEngineCallback) -> Result<()> { + Ok(FerryTask::for_reset_sync_id(self.new_bridge()?, callback)?.dispatch(&self.queue)?) + } + + xpcom_method!( + ensure_current_sync_id => EnsureCurrentSyncId( + new_sync_id: *const nsACString, + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn ensure_current_sync_id( + &self, + new_sync_id: &nsACString, + callback: &mozIBridgedSyncEngineCallback, + ) -> Result<()> { + Ok( + FerryTask::for_ensure_current_sync_id(self.new_bridge()?, new_sync_id, callback)? + .dispatch(&self.queue)?, + ) + } + + xpcom_method!( + sync_started => SyncStarted( + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn sync_started(&self, callback: &mozIBridgedSyncEngineCallback) -> Result<()> { + Ok(FerryTask::for_sync_started(self.new_bridge()?, callback)?.dispatch(&self.queue)?) + } + + xpcom_method!( + store_incoming => StoreIncoming( + incoming_envelopes_json: *const ThinVec<::nsstring::nsCString>, + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn store_incoming( + &self, + incoming_envelopes_json: Option<&ThinVec>, + callback: &mozIBridgedSyncEngineCallback, + ) -> Result<()> { + Ok(FerryTask::for_store_incoming( + self.new_bridge()?, + incoming_envelopes_json.map(|v| v.as_slice()).unwrap_or(&[]), + callback, + )? + .dispatch(&self.queue)?) + } + + xpcom_method!(apply => Apply(callback: *const mozIBridgedSyncEngineApplyCallback)); + fn apply(&self, callback: &mozIBridgedSyncEngineApplyCallback) -> Result<()> { + Ok(ApplyTask::new(self.new_bridge()?, callback)?.dispatch(&self.queue)?) + } + + xpcom_method!( + set_uploaded => SetUploaded( + server_modified_millis: i64, + uploaded_ids: *const ThinVec<::nsstring::nsCString>, + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn set_uploaded( + &self, + server_modified_millis: i64, + uploaded_ids: Option<&ThinVec>, + callback: &mozIBridgedSyncEngineCallback, + ) -> Result<()> { + Ok(FerryTask::for_set_uploaded( + self.new_bridge()?, + server_modified_millis, + uploaded_ids.map(|v| v.as_slice()).unwrap_or(&[]), + callback, + )? + .dispatch(&self.queue)?) + } + + xpcom_method!( + sync_finished => SyncFinished( + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn sync_finished(&self, callback: &mozIBridgedSyncEngineCallback) -> Result<()> { + Ok(FerryTask::for_sync_finished(self.new_bridge()?, callback)?.dispatch(&self.queue)?) + } + + xpcom_method!( + reset => Reset( + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn reset(&self, callback: &mozIBridgedSyncEngineCallback) -> Result<()> { + Ok(FerryTask::for_reset(self.new_bridge()?, callback)?.dispatch(&self.queue)?) + } + + xpcom_method!( + wipe => Wipe( + callback: *const mozIBridgedSyncEngineCallback + ) + ); + fn wipe(&self, callback: &mozIBridgedSyncEngineCallback) -> Result<()> { + Ok(FerryTask::for_wipe(self.new_bridge()?, callback)?.dispatch(&self.queue)?) + } + + fn new_bridge(&self) -> Result> { + Ok(Box::new(self.store()?.get()?.bridged_engine())) + } +} diff --git a/toolkit/components/extensions/storage/webext_storage_bridge/src/error.rs b/toolkit/components/extensions/storage/webext_storage_bridge/src/error.rs new file mode 100644 index 0000000000..877b2b21a8 --- /dev/null +++ b/toolkit/components/extensions/storage/webext_storage_bridge/src/error.rs @@ -0,0 +1,124 @@ +/* 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 std::{error, fmt, result, str::Utf8Error, string::FromUtf16Error}; + +use golden_gate::Error as GoldenGateError; +use nserror::{ + nsresult, NS_ERROR_ALREADY_INITIALIZED, NS_ERROR_CANNOT_CONVERT_DATA, + NS_ERROR_DOM_QUOTA_EXCEEDED_ERR, NS_ERROR_FAILURE, NS_ERROR_INVALID_ARG, + NS_ERROR_NOT_IMPLEMENTED, NS_ERROR_NOT_INITIALIZED, NS_ERROR_UNEXPECTED, +}; +use serde_json::error::Error as JsonError; +use webext_storage::error::Error as WebextStorageError; + +/// A specialized `Result` type for extension storage operations. +pub type Result = result::Result; + +/// The error type for extension storage operations. Errors can be converted +/// into `nsresult` codes, and include more detailed messages that can be passed +/// to callbacks. +#[derive(Debug)] +pub enum Error { + Nsresult(nsresult), + WebextStorage(WebextStorageError), + MigrationFailed(WebextStorageError), + GoldenGate(GoldenGateError), + MalformedString(Box), + AlreadyConfigured, + NotConfigured, + AlreadyRan(&'static str), + DidNotRun(&'static str), + AlreadyTornDown, + NotImplemented, +} + +impl error::Error for Error { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + match self { + Error::MalformedString(error) => Some(error.as_ref()), + _ => None, + } + } +} + +impl From for Error { + fn from(result: nsresult) -> Error { + Error::Nsresult(result) + } +} + +impl From for Error { + fn from(error: WebextStorageError) -> Error { + Error::WebextStorage(error) + } +} + +impl From for Error { + fn from(error: GoldenGateError) -> Error { + Error::GoldenGate(error) + } +} + +impl From for Error { + fn from(error: Utf8Error) -> Error { + Error::MalformedString(error.into()) + } +} + +impl From for Error { + fn from(error: FromUtf16Error) -> Error { + Error::MalformedString(error.into()) + } +} + +impl From for Error { + fn from(error: JsonError) -> Error { + Error::MalformedString(error.into()) + } +} + +impl From for nsresult { + fn from(error: Error) -> nsresult { + match error { + Error::Nsresult(result) => result, + Error::WebextStorage(e) => match e { + WebextStorageError::QuotaError(_) => NS_ERROR_DOM_QUOTA_EXCEEDED_ERR, + _ => NS_ERROR_FAILURE, + }, + Error::MigrationFailed(_) => NS_ERROR_CANNOT_CONVERT_DATA, + Error::GoldenGate(error) => error.into(), + Error::MalformedString(_) => NS_ERROR_INVALID_ARG, + Error::AlreadyConfigured => NS_ERROR_ALREADY_INITIALIZED, + Error::NotConfigured => NS_ERROR_NOT_INITIALIZED, + Error::AlreadyRan(_) => NS_ERROR_UNEXPECTED, + Error::DidNotRun(_) => NS_ERROR_UNEXPECTED, + Error::AlreadyTornDown => NS_ERROR_UNEXPECTED, + Error::NotImplemented => NS_ERROR_NOT_IMPLEMENTED, + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Error::Nsresult(result) => write!(f, "Operation failed with {result}"), + Error::WebextStorage(error) => error.fmt(f), + Error::MigrationFailed(error) => write!(f, "Migration failed with {error}"), + Error::GoldenGate(error) => error.fmt(f), + Error::MalformedString(error) => error.fmt(f), + Error::AlreadyConfigured => write!(f, "The storage area is already configured"), + Error::NotConfigured => write!( + f, + "The storage area must be configured by calling `configure` first" + ), + Error::AlreadyRan(what) => write!(f, "`{what}` already ran on the background thread"), + Error::DidNotRun(what) => write!(f, "`{what}` didn't run on the background thread"), + Error::AlreadyTornDown => { + write!(f, "Can't use a storage area that's already torn down") + } + Error::NotImplemented => write!(f, "Operation not implemented"), + } + } +} diff --git a/toolkit/components/extensions/storage/webext_storage_bridge/src/lib.rs b/toolkit/components/extensions/storage/webext_storage_bridge/src/lib.rs new file mode 100644 index 0000000000..94133ef1e9 --- /dev/null +++ b/toolkit/components/extensions/storage/webext_storage_bridge/src/lib.rs @@ -0,0 +1,65 @@ +/* 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/. */ + +#![allow(non_snake_case)] + +//! This crate bridges the WebExtension storage area interfaces in Firefox +//! Desktop to the extension storage Rust component in Application Services. +//! +//! ## How are the WebExtension storage APIs implemented in Firefox? +//! +//! There are three storage APIs available for WebExtensions: +//! `storage.local`, which is stored locally in an IndexedDB database and never +//! synced to other devices, `storage.sync`, which is stored in a local SQLite +//! database and synced to all devices signed in to the same Firefox Account, +//! and `storage.managed`, which is provisioned in a native manifest and +//! read-only. +//! +//! * `storage.local` is implemented in `ExtensionStorageIDB.jsm`. +//! * `storage.sync` is implemented in a Rust component, `webext_storage`. This +//! Rust component is vendored in m-c, and exposed to JavaScript via an XPCOM +//! API in `webext_storage_bridge` (this crate). Eventually, we'll change +//! `ExtensionStorageSync.jsm` to call the XPCOM API instead of using the +//! old Kinto storage adapter. +//! * `storage.managed` is implemented directly in `parent/ext-storage.js`. +//! +//! `webext_storage_bridge` implements the `mozIExtensionStorageArea` +//! (and, eventually, `mozIBridgedSyncEngine`) interface for `storage.sync`. The +//! implementation is in `area::StorageSyncArea`, and is backed by the +//! `webext_storage` component. + +#[macro_use] +extern crate cstr; +#[macro_use] +extern crate xpcom; + +mod area; +mod error; +mod punt; +mod store; + +use nserror::{nsresult, NS_OK}; +use xpcom::{interfaces::mozIExtensionStorageArea, RefPtr}; + +use crate::area::StorageSyncArea; + +/// The constructor for a `storage.sync` area. This uses C linkage so that it +/// can be called from C++. See `ExtensionStorageComponents.h` for the C++ +/// constructor that's passed to the component manager. +/// +/// # Safety +/// +/// This function is unsafe because it dereferences `result`. +#[no_mangle] +pub unsafe extern "C" fn NS_NewExtensionStorageSyncArea( + result: *mut *const mozIExtensionStorageArea, +) -> nsresult { + match StorageSyncArea::new() { + Ok(bridge) => { + RefPtr::new(bridge.coerce::()).forget(&mut *result); + NS_OK + } + Err(err) => err.into(), + } +} diff --git a/toolkit/components/extensions/storage/webext_storage_bridge/src/punt.rs b/toolkit/components/extensions/storage/webext_storage_bridge/src/punt.rs new file mode 100644 index 0000000000..4740237942 --- /dev/null +++ b/toolkit/components/extensions/storage/webext_storage_bridge/src/punt.rs @@ -0,0 +1,321 @@ +/* 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 std::{ + borrow::Borrow, + fmt::Write, + mem, result, str, + sync::{Arc, Weak}, +}; + +use atomic_refcell::AtomicRefCell; +use moz_task::{Task, ThreadPtrHandle, ThreadPtrHolder}; +use nserror::nsresult; +use nsstring::nsCString; +use serde::Serialize; +use serde_json::Value as JsonValue; +use storage_variant::VariantType; +use xpcom::{ + interfaces::{mozIExtensionStorageCallback, mozIExtensionStorageListener}, + RefPtr, XpCom, +}; + +use crate::error::{Error, Result}; +use crate::store::LazyStore; + +/// A storage operation that's punted from the main thread to the background +/// task queue. +pub enum Punt { + /// Get the values of the keys for an extension. + Get { ext_id: String, keys: JsonValue }, + /// Set a key-value pair for an extension. + Set { ext_id: String, value: JsonValue }, + /// Remove one or more keys for an extension. + Remove { ext_id: String, keys: JsonValue }, + /// Clear all keys and values for an extension. + Clear { ext_id: String }, + /// Returns the bytes in use for the specified, or all, keys. + GetBytesInUse { ext_id: String, keys: JsonValue }, + /// Fetches all pending Sync change notifications to pass to + /// `storage.onChanged` listeners. + FetchPendingSyncChanges, + /// Fetch-and-delete (e.g. `take`) information about the migration from the + /// kinto-based extension-storage to the rust-based storage. + /// + /// This data is stored in the database instead of just being returned by + /// the call to `migrate`, as we may migrate prior to telemetry being ready. + TakeMigrationInfo, +} + +impl Punt { + /// Returns the operation name, used to label the task runnable and report + /// errors. + pub fn name(&self) -> &'static str { + match self { + Punt::Get { .. } => "webext_storage::get", + Punt::Set { .. } => "webext_storage::set", + Punt::Remove { .. } => "webext_storage::remove", + Punt::Clear { .. } => "webext_storage::clear", + Punt::GetBytesInUse { .. } => "webext_storage::get_bytes_in_use", + Punt::FetchPendingSyncChanges => "webext_storage::fetch_pending_sync_changes", + Punt::TakeMigrationInfo => "webext_storage::take_migration_info", + } + } +} + +/// A storage operation result, punted from the background queue back to the +/// main thread. +#[derive(Default)] +struct PuntResult { + changes: Vec, + value: Option, +} + +/// A change record for an extension. +struct Change { + ext_id: String, + json: String, +} + +impl PuntResult { + /// Creates a result with a single change to pass to `onChanged`, and no + /// return value for `handleSuccess`. The `Borrow` bound lets this method + /// take either a borrowed reference or an owned value. + fn with_change, S: Serialize>(ext_id: &str, changes: T) -> Result { + Ok(PuntResult { + changes: vec![Change { + ext_id: ext_id.into(), + json: serde_json::to_string(changes.borrow())?, + }], + value: None, + }) + } + + /// Creates a result with changes for multiple extensions to pass to + /// `onChanged`, and no return value for `handleSuccess`. + fn with_changes(changes: Vec) -> Self { + PuntResult { + changes, + value: None, + } + } + + /// Creates a result with no changes to pass to `onChanged`, and a return + /// value for `handleSuccess`. + fn with_value, S: Serialize>(value: T) -> Result { + Ok(PuntResult { + changes: Vec::new(), + value: Some(serde_json::to_string(value.borrow())?), + }) + } +} + +/// A generic task used for all storage operations. Punts the operation to the +/// background task queue, receives a result back on the main thread, and calls +/// the callback with it. +pub struct PuntTask { + name: &'static str, + /// Storage tasks hold weak references to the store, which they upgrade + /// to strong references when running on the background queue. This + /// ensures that pending storage tasks don't block teardown (for example, + /// if a consumer calls `get` and then `teardown`, without waiting for + /// `get` to finish). + store: Weak, + punt: AtomicRefCell>, + callback: ThreadPtrHandle, + result: AtomicRefCell>, +} + +impl PuntTask { + /// Creates a storage task that punts an operation to the background queue. + /// Returns an error if the task couldn't be created because the thread + /// manager is shutting down. + pub fn new( + store: Weak, + punt: Punt, + callback: &mozIExtensionStorageCallback, + ) -> Result { + let name = punt.name(); + Ok(Self { + name, + store, + punt: AtomicRefCell::new(Some(punt)), + callback: ThreadPtrHolder::new( + cstr!("mozIExtensionStorageCallback"), + RefPtr::new(callback), + )?, + result: AtomicRefCell::new(Err(Error::DidNotRun(name))), + }) + } + + /// Upgrades the task's weak `LazyStore` reference to a strong one. Returns + /// an error if the store has been torn down. + /// + /// It's important that this is called on the background queue, after the + /// task has been dispatched. Storage tasks shouldn't hold strong references + /// to the store on the main thread, because then they might block teardown. + fn store(&self) -> Result> { + match self.store.upgrade() { + Some(store) => Ok(store), + None => Err(Error::AlreadyTornDown), + } + } + + /// Runs this task's storage operation on the background queue. + fn inner_run(&self, punt: Punt) -> Result { + Ok(match punt { + Punt::Set { ext_id, value } => { + PuntResult::with_change(&ext_id, self.store()?.get()?.set(&ext_id, value)?)? + } + Punt::Get { ext_id, keys } => { + PuntResult::with_value(self.store()?.get()?.get(&ext_id, keys)?)? + } + Punt::Remove { ext_id, keys } => { + PuntResult::with_change(&ext_id, self.store()?.get()?.remove(&ext_id, keys)?)? + } + Punt::Clear { ext_id } => { + PuntResult::with_change(&ext_id, self.store()?.get()?.clear(&ext_id)?)? + } + Punt::GetBytesInUse { ext_id, keys } => { + PuntResult::with_value(self.store()?.get()?.get_bytes_in_use(&ext_id, keys)?)? + } + Punt::FetchPendingSyncChanges => PuntResult::with_changes( + self.store()? + .get()? + .get_synced_changes()? + .into_iter() + .map(|info| Change { + ext_id: info.ext_id, + json: info.changes, + }) + .collect(), + ), + Punt::TakeMigrationInfo => { + PuntResult::with_value(self.store()?.get()?.take_migration_info()?)? + } + }) + } +} + +impl Task for PuntTask { + fn run(&self) { + *self.result.borrow_mut() = match self.punt.borrow_mut().take() { + Some(punt) => self.inner_run(punt), + // A task should never run on the background queue twice, but we + // return an error just in case. + None => Err(Error::AlreadyRan(self.name)), + }; + } + + fn done(&self) -> result::Result<(), nsresult> { + let callback = self.callback.get().unwrap(); + // As above, `done` should never be called multiple times, but we handle + // that by returning an error. + match mem::replace( + &mut *self.result.borrow_mut(), + Err(Error::AlreadyRan(self.name)), + ) { + Ok(PuntResult { changes, value }) => { + // If we have change data, and the callback implements the + // listener interface, notify about it first. + if let Some(listener) = callback.query_interface::() { + for Change { ext_id, json } in changes { + // Ignore errors. + let _ = unsafe { + listener.OnChanged(&*nsCString::from(ext_id), &*nsCString::from(json)) + }; + } + } + let result = value.map(nsCString::from).into_variant(); + unsafe { callback.HandleSuccess(result.coerce()) } + } + Err(err) => { + let mut message = nsCString::new(); + write!(message, "{err}").unwrap(); + unsafe { callback.HandleError(err.into(), &*message) } + } + } + .to_result() + } +} + +/// A task to tear down the store on the background task queue. +pub struct TeardownTask { + /// Unlike storage tasks, the teardown task holds a strong reference to + /// the store, which it drops on the background queue. This is the only + /// task that should do that. + store: AtomicRefCell>>, + callback: ThreadPtrHandle, + result: AtomicRefCell>, +} + +impl TeardownTask { + /// Creates a teardown task. This should only be created and dispatched + /// once, to clean up the store at shutdown. Returns an error if the task + /// couldn't be created because the thread manager is shutting down. + pub fn new(store: Arc, callback: &mozIExtensionStorageCallback) -> Result { + Ok(Self { + store: AtomicRefCell::new(Some(store)), + callback: ThreadPtrHolder::new( + cstr!("mozIExtensionStorageCallback"), + RefPtr::new(callback), + )?, + result: AtomicRefCell::new(Err(Error::DidNotRun(Self::name()))), + }) + } + + /// Returns the task name, used to label its runnable and report errors. + pub fn name() -> &'static str { + "webext_storage::teardown" + } + + /// Tears down and drops the store on the background queue. + fn inner_run(&self, store: Arc) -> Result<()> { + // At this point, we should be holding the only strong reference + // to the store, since 1) `StorageSyncArea` gave its one strong + // reference to our task, and 2) we're running on a background + // task queue, which runs all tasks sequentially...so no other + // `PuntTask`s should be running and trying to upgrade their + // weak references. So we can unwrap the `Arc` and take ownership + // of the store. + match Arc::try_unwrap(store) { + Ok(store) => store.teardown(), + Err(_) => { + // If unwrapping the `Arc` fails, someone else must have + // a strong reference to the store. We could sleep and + // try again, but this is so unexpected that it's easier + // to just leak the store, and return an error to the + // callback. Except in tests, we only call `teardown` at + // shutdown, so the resources will get reclaimed soon, + // anyway. + Err(Error::DidNotRun(Self::name())) + } + } + } +} + +impl Task for TeardownTask { + fn run(&self) { + *self.result.borrow_mut() = match self.store.borrow_mut().take() { + Some(store) => self.inner_run(store), + None => Err(Error::AlreadyRan(Self::name())), + }; + } + + fn done(&self) -> result::Result<(), nsresult> { + let callback = self.callback.get().unwrap(); + match mem::replace( + &mut *self.result.borrow_mut(), + Err(Error::AlreadyRan(Self::name())), + ) { + Ok(()) => unsafe { callback.HandleSuccess(().into_variant().coerce()) }, + Err(err) => { + let mut message = nsCString::new(); + write!(message, "{err}").unwrap(); + unsafe { callback.HandleError(err.into(), &*message) } + } + } + .to_result() + } +} diff --git a/toolkit/components/extensions/storage/webext_storage_bridge/src/store.rs b/toolkit/components/extensions/storage/webext_storage_bridge/src/store.rs new file mode 100644 index 0000000000..cb1ce07784 --- /dev/null +++ b/toolkit/components/extensions/storage/webext_storage_bridge/src/store.rs @@ -0,0 +1,136 @@ +/* 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 std::{fs::remove_file, path::PathBuf, sync::Arc}; + +use interrupt_support::SqlInterruptHandle; +use once_cell::sync::OnceCell; +use webext_storage::store::WebExtStorageStore as Store; + +use crate::error::{self, Error}; + +/// Options for an extension storage area. +pub struct LazyStoreConfig { + /// The path to the database file for this storage area. + pub path: PathBuf, + /// The path to the old kinto database. If it exists, we should attempt to + /// migrate from this database as soon as we open our DB. It's not Option<> + /// because the caller will not have checked whether it exists or not, so + /// will assume it might. + pub kinto_path: PathBuf, +} + +/// A lazy store is automatically initialized on a background thread with its +/// configuration the first time it's used. +#[derive(Default)] +pub struct LazyStore { + store: OnceCell, + config: OnceCell, +} + +/// An `InterruptStore` wraps an inner extension store, and its interrupt +/// handle. +struct InterruptStore { + inner: Store, + handle: Arc, +} + +impl LazyStore { + /// Configures the lazy store. Returns an error if the store has already + /// been configured. This method should be called from the main thread. + pub fn configure(&self, config: LazyStoreConfig) -> error::Result<()> { + self.config + .set(config) + .map_err(|_| Error::AlreadyConfigured) + } + + /// Interrupts all pending operations on the store. If a database statement + /// is currently running, this will interrupt that statement. If the + /// statement is a write inside an active transaction, the entire + /// transaction will be rolled back. This method should be called from the + /// main thread. + pub fn interrupt(&self) { + if let Some(outer) = self.store.get() { + outer.handle.interrupt(); + } + } + + /// Returns the underlying store, initializing it if needed. This method + /// should only be called from a background thread or task queue, since + /// opening the database does I/O. + pub fn get(&self) -> error::Result<&Store> { + Ok(&self + .store + .get_or_try_init(|| match self.config.get() { + Some(config) => { + let store = init_store(config)?; + let handle = store.interrupt_handle(); + Ok(InterruptStore { + inner: store, + handle, + }) + } + None => Err(Error::NotConfigured), + })? + .inner) + } + + /// Tears down the store. If the store wasn't initialized, this is a no-op. + /// This should only be called from a background thread or task queue, + /// because closing the database also does I/O. + pub fn teardown(self) -> error::Result<()> { + if let Some(store) = self.store.into_inner() { + store.inner.close()?; + } + Ok(()) + } +} + +// Initialize the store, performing a migration if necessary. +// The requirements for migration are, roughly: +// * If kinto_path doesn't exist, we don't try to migrate. +// * If our DB path exists, we assume we've already migrated and don't try again +// * If the migration fails, we close our store and delete the DB, then return +// a special error code which tells our caller about the failure. It's then +// expected to fallback to the "old" kinto store and we'll try next time. +// Note that the migrate() method on the store is written such that is should +// ignore all "read" errors from the source, but propagate "write" errors on our +// DB - the intention is that things like corrupted source databases never fail, +// but disk-space failures on our database does. +fn init_store(config: &LazyStoreConfig) -> error::Result { + let should_migrate = config.kinto_path.exists() && !config.path.exists(); + let store = Store::new(&config.path)?; + if should_migrate { + match store.migrate(&config.kinto_path) { + // It's likely to be too early for us to stick the MigrationInfo + // into the sync telemetry, a separate call to `take_migration_info` + // must be made to the store (this is done by telemetry after it's + // ready to submit the data). + Ok(()) => { + // need logging, but for now let's print to stdout. + println!("extension-storage: migration complete"); + Ok(store) + } + Err(e) => { + println!("extension-storage: migration failure: {e}"); + if let Err(e) = store.close() { + // welp, this probably isn't going to end well... + println!( + "extension-storage: failed to close the store after migration failure: {e}" + ); + } + if let Err(e) = remove_file(&config.path) { + // this is bad - if it happens regularly it will defeat + // out entire migration strategy - we'll assume it + // worked. + // So it's desirable to make noise if this happens. + println!("Failed to remove file after failed migration: {e}"); + } + Err(Error::MigrationFailed(e)) + } + } + } else { + Ok(store) + } +} diff --git a/toolkit/components/extensions/test/browser/.eslintrc.js b/toolkit/components/extensions/test/browser/.eslintrc.js new file mode 100644 index 0000000000..ef228570e3 --- /dev/null +++ b/toolkit/components/extensions/test/browser/.eslintrc.js @@ -0,0 +1,11 @@ +"use strict"; + +module.exports = { + env: { + webextensions: true, + }, + + rules: { + "no-shadow": "off", + }, +}; diff --git a/toolkit/components/extensions/test/browser/browser-serviceworker.toml b/toolkit/components/extensions/test/browser/browser-serviceworker.toml new file mode 100644 index 0000000000..1d7c7c3ffe --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser-serviceworker.toml @@ -0,0 +1,9 @@ +[DEFAULT] +support-files = [ + "head_serviceworker.js", + "data/**", +] + +prefs = ["extensions.backgroundServiceWorker.enabled=true"] + +["browser_ext_background_serviceworker.js"] diff --git a/toolkit/components/extensions/test/browser/browser.toml b/toolkit/components/extensions/test/browser/browser.toml new file mode 100644 index 0000000000..33d54bddc2 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser.toml @@ -0,0 +1,103 @@ +[DEFAULT] +support-files = [ + "head.js", + "data/**" +] + +["browser_ext_background_serviceworker_pref_disabled.js"] + +["browser_ext_downloads_filters.js"] + +["browser_ext_downloads_referrer.js"] +https_first_disabled = true + +["browser_ext_eventpage_disableResetIdleForTest.js"] + +["browser_ext_extension_page_tab_navigated.js"] + +["browser_ext_management_themes.js"] +skip-if = ["verify"] + +["browser_ext_process_crash_handling.js"] +skip-if = ["!crashreporter"] + +["browser_ext_test_mock.js"] + +["browser_ext_themes_additional_backgrounds_alignment.js"] + +["browser_ext_themes_alpha_accentcolor.js"] + +["browser_ext_themes_arrowpanels.js"] + +["browser_ext_themes_autocomplete_popup.js"] + +["browser_ext_themes_chromeparity.js"] + +["browser_ext_themes_dynamic_getCurrent.js"] + +["browser_ext_themes_dynamic_onUpdated.js"] + +["browser_ext_themes_dynamic_updates.js"] + +["browser_ext_themes_experiment.js"] + +["browser_ext_themes_findbar.js"] + +["browser_ext_themes_getCurrent_differentExt.js"] + +["browser_ext_themes_highlight.js"] + +["browser_ext_themes_incognito.js"] + +["browser_ext_themes_lwtsupport.js"] + +["browser_ext_themes_multiple_backgrounds.js"] + +["browser_ext_themes_ntp_colors.js"] + +["browser_ext_themes_ntp_colors_perwindow.js"] + +["browser_ext_themes_pbm.js"] + +["browser_ext_themes_persistence.js"] + +["browser_ext_themes_reset.js"] + +["browser_ext_themes_sanitization.js"] + +["browser_ext_themes_separators.js"] + +["browser_ext_themes_sidebars.js"] + +["browser_ext_themes_static_onUpdated.js"] + +["browser_ext_themes_tab_line.js"] + +["browser_ext_themes_tab_loading.js"] + +["browser_ext_themes_tab_selected.js"] + +["browser_ext_themes_tab_text.js"] + +["browser_ext_themes_theme_transition.js"] + +["browser_ext_themes_toolbar_fields.js"] + +["browser_ext_themes_toolbar_fields_focus.js"] + +["browser_ext_themes_toolbarbutton_colors.js"] + +["browser_ext_themes_toolbarbutton_icons.js"] + +["browser_ext_themes_toolbars.js"] + +["browser_ext_themes_warnings.js"] + +["browser_ext_thumbnails_bg_extension.js"] +support-files = ["!/toolkit/components/thumbnails/test/head.js"] + +["browser_ext_webNavigation_eventpage.js"] + +["browser_ext_webRequest_redirect_mozextension.js"] + +["browser_ext_windows_popup_title.js"] diff --git a/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker.js b/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker.js new file mode 100644 index 0000000000..153818f4de --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker.js @@ -0,0 +1,285 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* globals getBackgroundServiceWorkerRegistration, waitForServiceWorkerTerminated */ + +Services.scriptloader.loadSubScript( + new URL("head_serviceworker.js", gTestPath).href, + this +); + +add_task(assert_background_serviceworker_pref_enabled); + +add_task(async function test_serviceWorker_register_guarded_by_pref() { + // Test with backgroundServiceWorkeEnable set to true and the + // extensions.serviceWorkerRegist.allowed pref set to false. + // NOTE: the scenario with backgroundServiceWorkeEnable set to false + // is part of "browser_ext_background_serviceworker_pref_disabled.js". + + await SpecialPowers.pushPrefEnv({ + set: [["extensions.serviceWorkerRegister.allowed", false]], + }); + + let extensionData = { + files: { + "page.html": "", + "page.js": async function () { + browser.test.assertEq( + undefined, + navigator.serviceWorker, + "navigator.serviceWorker should be undefined" + ); + browser.test.sendMessage("test-serviceWorker-register-disallowed"); + }, + "sw.js": "", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + // Verify that an extension page can't register a moz-extension url + // as a service worker. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: `moz-extension://${extension.uuid}/page.html`, + }, + async () => { + await extension.awaitMessage("test-serviceWorker-register-disallowed"); + } + ); + + await extension.unload(); + + await SpecialPowers.popPrefEnv(); + + // Test again with the pref set to true. + + await SpecialPowers.pushPrefEnv({ + set: [["extensions.serviceWorkerRegister.allowed", true]], + }); + + extension = ExtensionTestUtils.loadExtension({ + files: { + ...extensionData.files, + "page.js": async function () { + try { + await navigator.serviceWorker.register("sw.js"); + } catch (err) { + browser.test.fail( + `Unexpected error on registering a service worker: ${err}` + ); + throw err; + } finally { + browser.test.sendMessage("test-serviceworker-register-allowed"); + } + }, + }, + }); + await extension.startup(); + + // Verify that an extension page can register a moz-extension url + // as a service worker if enabled by the related pref. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: `moz-extension://${extension.uuid}/page.html`, + }, + async () => { + await extension.awaitMessage("test-serviceworker-register-allowed"); + } + ); + + await extension.unload(); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_cache_api_allowed() { + // Verify that Cache API support for moz-extension url availability is + // conditioned only by the extensions.backgroundServiceWorker.enabled pref. + // NOTE: the scenario with backgroundServiceWorkeEnable set to false + // is part of "browser_ext_background_serviceworker_pref_disabled.js". + const extension = ExtensionTestUtils.loadExtension({ + async background() { + try { + let cache = await window.caches.open("test-cache-api"); + browser.test.assertTrue( + await window.caches.has("test-cache-api"), + "CacheStorage.has should resolve to true" + ); + + // Test that adding and requesting cached moz-extension urls + // works as well. + let url = browser.runtime.getURL("file.txt"); + await cache.add(url); + const content = await cache.match(url).then(res => res.text()); + browser.test.assertEq( + "file content", + content, + "Got the expected content from the cached moz-extension url" + ); + + // Test that deleting the cache storage works as expected. + browser.test.assertTrue( + await window.caches.delete("test-cache-api"), + "Cache deleted successfully" + ); + browser.test.assertTrue( + !(await window.caches.has("test-cache-api")), + "CacheStorage.has should resolve to false" + ); + } catch (err) { + browser.test.fail(`Unexpected error on using Cache API: ${err}`); + throw err; + } finally { + browser.test.sendMessage("test-cache-api-allowed"); + } + }, + files: { + "file.txt": "file content", + }, + }); + + await extension.startup(); + await extension.awaitMessage("test-cache-api-allowed"); + await extension.unload(); +}); + +function createTestSWScript({ postMessageReply }) { + return ` + self.onmessage = msg => { + dump("Background ServiceWorker - onmessage handler\\n"); + msg.ports[0].postMessage("${postMessageReply}"); + dump("Background ServiceWorker - postMessage\\n"); + }; + dump("Background ServiceWorker - executed\\n"); + `; +} + +async function testServiceWorker({ extension, expectMessageReply }) { + // Verify that the WebExtensions framework has successfully registered the + // background service worker declared in the extension manifest. + const swRegInfo = getBackgroundServiceWorkerRegistration(extension); + + // Activate the background service worker by exchanging a message + // with it. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: `moz-extension://${extension.uuid}/page.html`, + }, + async browser => { + let msgFromV1 = await SpecialPowers.spawn( + browser, + [swRegInfo.scriptURL], + async url => { + const { active } = await content.navigator.serviceWorker.ready; + const { port1, port2 } = new content.MessageChannel(); + + return new Promise(resolve => { + port1.onmessage = msg => resolve(msg.data); + active.postMessage("test", [port2]); + }); + } + ); + + Assert.deepEqual( + msgFromV1, + expectMessageReply, + "Got the expected reply from the extension service worker" + ); + } + ); +} + +function loadTestExtension({ version }) { + const postMessageReply = `reply:sw-v${version}`; + + return ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + version, + background: { + service_worker: "sw.js", + }, + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + files: { + "page.html": "", + "sw.js": createTestSWScript({ postMessageReply }), + }, + }); +} + +async function assertWorkerIsRunningInExtensionProcess(extension) { + // Activate the background service worker by exchanging a message + // with it. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: `moz-extension://${extension.uuid}/page.html`, + }, + async browser => { + const workerScriptURL = `moz-extension://${extension.uuid}/sw.js`; + const workerDebuggerURLs = await SpecialPowers.spawn( + browser, + [workerScriptURL], + async url => { + await content.navigator.serviceWorker.ready; + const wdm = Cc[ + "@mozilla.org/dom/workers/workerdebuggermanager;1" + ].getService(Ci.nsIWorkerDebuggerManager); + + return Array.from(wdm.getWorkerDebuggerEnumerator()) + .map(wd => { + return wd.url; + }) + .filter(swURL => swURL == url); + } + ); + + Assert.deepEqual( + workerDebuggerURLs, + [workerScriptURL], + "The worker should be running in the extension child process" + ); + } + ); +} + +add_task(async function test_background_serviceworker_with_no_ext_apis() { + const extensionV1 = loadTestExtension({ version: "1" }); + await extensionV1.startup(); + + const swRegInfo = getBackgroundServiceWorkerRegistration(extensionV1); + const { uuid } = extensionV1; + + await assertWorkerIsRunningInExtensionProcess(extensionV1); + await testServiceWorker({ + extension: extensionV1, + expectMessageReply: "reply:sw-v1", + }); + + // Load a new version of the same addon and verify that the + // expected worker script is being executed. + const extensionV2 = loadTestExtension({ version: "2" }); + await extensionV2.startup(); + is(extensionV2.uuid, uuid, "The extension uuid did not change as expected"); + + await testServiceWorker({ + extension: extensionV2, + expectMessageReply: "reply:sw-v2", + }); + + await Promise.all([ + extensionV2.unload(), + // test extension v1 wrapper has to be unloaded explicitly, otherwise + // will be detected as a failure by the test harness. + extensionV1.unload(), + ]); + await waitForServiceWorkerTerminated(swRegInfo); + await waitForServiceWorkerRegistrationsRemoved(extensionV2); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker_pref_disabled.js b/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker_pref_disabled.js new file mode 100644 index 0000000000..0194cd237f --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker_pref_disabled.js @@ -0,0 +1,126 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function assert_background_serviceworker_pref_disabled() { + is( + WebExtensionPolicy.backgroundServiceWorkerEnabled, + false, + "Expect extensions.backgroundServiceWorker.enabled to be false" + ); +}); + +add_task(async function test_background_serviceworker_disallowed() { + const id = "test-disallowed-worker@test"; + + const extensionData = { + manifest: { + background: { + service_worker: "sw.js", + }, + applicantions: { gecko: { id } }, + useAddonManager: "temporary", + }, + }; + + SimpleTest.waitForExplicitFinish(); + let waitForConsole = new Promise(resolve => { + SimpleTest.monitorConsole(resolve, [ + { + message: + /Reading manifest: Error processing background: background.service_worker is currently disabled/, + }, + ]); + }); + + const extension = ExtensionTestUtils.loadExtension(extensionData); + await Assert.rejects( + extension.startup(), + /startup failed/, + "Startup failed with background.service_worker while disabled by pref" + ); + + SimpleTest.endMonitorConsole(); + await waitForConsole; +}); + +add_task(async function test_serviceWorker_register_disallowed() { + // Verify that setting extensions.serviceWorkerRegist.allowed pref to false + // doesn't allow serviceWorker.register if backgroundServiceWorkeEnable is + // set to false + + await SpecialPowers.pushPrefEnv({ + set: [["extensions.serviceWorkerRegister.allowed", true]], + }); + + let extensionData = { + files: { + "page.html": "", + "page.js": async function () { + try { + await navigator.serviceWorker.register("sw.js"); + browser.test.fail( + `An extension page should not be able to register a serviceworker successfully` + ); + } catch (err) { + browser.test.assertEq( + String(err), + "SecurityError: The operation is insecure.", + "Got the expected error on registering a service worker from a script" + ); + } + browser.test.sendMessage("test-serviceWorker-register-disallowed"); + }, + "sw.js": "", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + // Verify that an extension page can't register a moz-extension url + // as a service worker. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: `moz-extension://${extension.uuid}/page.html`, + }, + async () => { + await extension.awaitMessage("test-serviceWorker-register-disallowed"); + } + ); + + await extension.unload(); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_cache_api_disallowed() { + // Verify that Cache API support for moz-extension url availability is also + // conditioned by the extensions.backgroundServiceWorker.enabled pref. + const extension = ExtensionTestUtils.loadExtension({ + async background() { + try { + const cache = await window.caches.open("test-cache-api"); + let url = browser.runtime.getURL("file.txt"); + await browser.test.assertRejects( + cache.add(url), + new RegExp(`Cache.add: Request URL ${url} must be either`), + "Got the expected rejections on calling cache.add with a moz-extension:// url" + ); + } catch (err) { + browser.test.fail(`Unexpected error on using Cache API: ${err}`); + throw err; + } finally { + browser.test.sendMessage("test-cache-api-disallowed"); + } + }, + files: { + "file.txt": "file content", + }, + }); + + await extension.startup(); + await extension.awaitMessage("test-cache-api-disallowed"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_downloads_filters.js b/toolkit/components/extensions/test/browser/browser_ext_downloads_filters.js new file mode 100644 index 0000000000..48dd6b88ee --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_downloads_filters.js @@ -0,0 +1,139 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +async function testAppliedFilters(ext, expectedFilter, expectedFilterCount) { + let tempDir = FileUtils.getDir("TmpD", [`testDownloadDir-${Math.random()}`]); + tempDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + let filterCount = 0; + + let MockFilePicker = SpecialPowers.MockFilePicker; + MockFilePicker.init(window); + MockFilePicker.displayDirectory = tempDir; + MockFilePicker.returnValue = MockFilePicker.returnCancel; + MockFilePicker.appendFiltersCallback = function (fp, val) { + const hexstr = "0x" + ("000" + val.toString(16)).substr(-3); + filterCount++; + if (filterCount < expectedFilterCount) { + is(val, expectedFilter, "Got expected filter: " + hexstr); + } else if (filterCount == expectedFilterCount) { + is(val, MockFilePicker.filterAll, "Got all files filter: " + hexstr); + } else { + is(val, null, "Got unexpected filter: " + hexstr); + } + }; + MockFilePicker.showCallback = function (fp) { + const filename = fp.defaultString; + info("MockFilePicker - save as: " + filename); + }; + + let manifest = { + description: ext, + permissions: ["downloads"], + }; + + const extension = ExtensionTestUtils.loadExtension({ + manifest: manifest, + + background: async function () { + let ext = chrome.runtime.getManifest().description; + await browser.test.assertRejects( + browser.downloads.download({ + url: "http://any-origin/any-path/any-resource", + filename: "any-file" + ext, + saveAs: true, + }), + "Download canceled by the user", + "expected request to be canceled" + ); + browser.test.sendMessage("canceled"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("canceled"); + await extension.unload(); + + is( + filterCount, + expectedFilterCount, + "Got correct number of filters: " + filterCount + ); + + MockFilePicker.cleanup(); + + tempDir.remove(true); +} + +// Missing extension +add_task(async function testDownload_missing_All() { + await testAppliedFilters("", null, 1); +}); + +// Unrecognized extension +add_task(async function testDownload_unrecognized_All() { + await testAppliedFilters(".xxx", null, 1); +}); + +// Recognized extensions +add_task(async function testDownload_html_HTML() { + await testAppliedFilters(".html", Ci.nsIFilePicker.filterHTML, 2); +}); + +add_task(async function testDownload_xhtml_HTML() { + await testAppliedFilters(".xhtml", Ci.nsIFilePicker.filterHTML, 2); +}); + +add_task(async function testDownload_txt_Text() { + await testAppliedFilters(".txt", Ci.nsIFilePicker.filterText, 2); +}); + +add_task(async function testDownload_text_Text() { + await testAppliedFilters(".text", Ci.nsIFilePicker.filterText, 2); +}); + +add_task(async function testDownload_jpe_Images() { + await testAppliedFilters(".jpe", Ci.nsIFilePicker.filterImages, 2); +}); + +add_task(async function testDownload_tif_Images() { + await testAppliedFilters(".tif", Ci.nsIFilePicker.filterImages, 2); +}); + +add_task(async function testDownload_webp_Images() { + await testAppliedFilters(".webp", Ci.nsIFilePicker.filterImages, 2); +}); + +add_task(async function testDownload_heic_Images() { + await testAppliedFilters(".heic", Ci.nsIFilePicker.filterImages, 2); +}); + +add_task(async function testDownload_xml_XML() { + await testAppliedFilters(".xml", Ci.nsIFilePicker.filterXML, 2); +}); + +add_task(async function testDownload_aac_Audio() { + await testAppliedFilters(".aac", Ci.nsIFilePicker.filterAudio, 2); +}); + +add_task(async function testDownload_mp3_Audio() { + await testAppliedFilters(".mp3", Ci.nsIFilePicker.filterAudio, 2); +}); + +add_task(async function testDownload_wma_Audio() { + await testAppliedFilters(".wma", Ci.nsIFilePicker.filterAudio, 2); +}); + +add_task(async function testDownload_avi_Video() { + await testAppliedFilters(".avi", Ci.nsIFilePicker.filterVideo, 2); +}); + +add_task(async function testDownload_mp4_Video() { + await testAppliedFilters(".mp4", Ci.nsIFilePicker.filterVideo, 2); +}); + +add_task(async function testDownload_xvid_Video() { + await testAppliedFilters(".xvid", Ci.nsIFilePicker.filterVideo, 2); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_downloads_referrer.js b/toolkit/components/extensions/test/browser/browser_ext_downloads_referrer.js new file mode 100644 index 0000000000..9690df6376 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_downloads_referrer.js @@ -0,0 +1,91 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +const URL_PATH = "browser/toolkit/components/extensions/test/browser/data"; +const TEST_URL = `http://example.com/${URL_PATH}/test_downloads_referrer.html`; +const DOWNLOAD_URL = `http://example.com/${URL_PATH}/test-download.txt`; + +async function triggerSaveAs({ selector }) { + const contextMenu = window.document.getElementById("contentAreaContextMenu"); + const popupshown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + await BrowserTestUtils.synthesizeMouseAtCenter( + selector, + { type: "contextmenu", button: 2 }, + gBrowser.selectedBrowser + ); + await popupshown; + let saveLinkCommand = window.document.getElementById("context-savelink"); + contextMenu.activateItem(saveLinkCommand); +} + +add_setup(() => { + const tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + tempDir.append("test-download-dir"); + if (!tempDir.exists()) { + tempDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + } + + let MockFilePicker = SpecialPowers.MockFilePicker; + MockFilePicker.init(window); + registerCleanupFunction(function () { + MockFilePicker.cleanup(); + + if (tempDir.exists()) { + tempDir.remove(true); + } + }); + + MockFilePicker.displayDirectory = tempDir; + MockFilePicker.showCallback = function (fp) { + info("MockFilePicker: shown"); + const filename = fp.defaultString; + info("MockFilePicker: save as " + filename); + const destFile = tempDir.clone(); + destFile.append(filename); + MockFilePicker.setFiles([destFile]); + info("MockFilePicker: showCallback done"); + }; +}); + +add_task(async function test_download_item_referrer_info() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["downloads"], + }, + async background() { + browser.downloads.onCreated.addListener(async downloadInfo => { + browser.test.sendMessage("download-on-created", downloadInfo); + }); + browser.downloads.onChanged.addListener(async downloadInfo => { + // Wait download to be completed. + if (downloadInfo.state?.current !== "complete") { + return; + } + browser.test.sendMessage("download-completed"); + }); + + // Call an API method implemented in the parent process to make sure + // registering the downloas.onCreated event listener has been completed. + await browser.runtime.getBrowserInfo(); + + browser.test.sendMessage("bg-page:ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg-page:ready"); + + await BrowserTestUtils.withNewTab({ gBrowser, url: TEST_URL }, async () => { + await triggerSaveAs({ selector: "a.test-link" }); + const downloadInfo = await extension.awaitMessage("download-on-created"); + is(downloadInfo.url, DOWNLOAD_URL, "Got the expected download url"); + is(downloadInfo.referrer, TEST_URL, "Got the expected referrer"); + }); + + // Wait for the download to have been completed and removed. + await extension.awaitMessage("download-completed"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_eventpage_disableResetIdleForTest.js b/toolkit/components/extensions/test/browser/browser_ext_eventpage_disableResetIdleForTest.js new file mode 100644 index 0000000000..8178411e80 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_eventpage_disableResetIdleForTest.js @@ -0,0 +1,83 @@ +"use strict"; + +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); + +const { AppUiTestDelegate } = ChromeUtils.importESModule( + "resource://testing-common/AppUiTestDelegate.sys.mjs" +); + +// Ignore error "Actor 'Conduits' destroyed before query 'RunListener' was resolved" +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Actor 'Conduits' destroyed before query 'RunListener'/ +); + +async function run_test_disableResetIdleForTest(options) { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + action: {}, + }, + background() { + browser.action.onClicked.addListener(async () => { + browser.test.notifyPass("action-clicked"); + // Deliberately keep this listener active to simulate a still active listener + // callback, while calling extension.terminateBackground(). + await new Promise(() => {}); + }); + + browser.test.sendMessage("background-ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + // After startup, the listener should be persistent but not primed. + assertPersistentListeners(extension, "browserAction", "onClicked", { + primed: false, + }); + + // Terminating the background should prime the persistent listener. + await extension.terminateBackground(); + assertPersistentListeners(extension, "browserAction", "onClicked", { + primed: true, + }); + + // Wake up the background, and verify the listener is no longer primed. + await AppUiTestDelegate.clickBrowserAction(window, extension.id); + await extension.awaitFinish("action-clicked"); + await AppUiTestDelegate.closeBrowserAction(window, extension.id); + await extension.awaitMessage("background-ready"); + assertPersistentListeners(extension, "browserAction", "onClicked", { + primed: false, + }); + + // Terminate the background again, while the onClicked listener is still + // being executed. + // With options.disableResetIdleForTest = true, the termination should NOT + // be skipped and the listener should become primed again. + // With options.disableResetIdleForTest = false or unset, the termination + // should be skipped and the listener should not become primed. + await extension.terminateBackground(options); + assertPersistentListeners(extension, "browserAction", "onClicked", { + primed: !!options?.disableResetIdleForTest, + }); + + await extension.unload(); +} + +// Verify default behaviour when terminating a background while a +// listener is still running: The background should not be terminated +// and the listener should not become primed. Not specifyiny a value +// for disableResetIdleForTest defauls to disableResetIdleForTest:false. +add_task(async function test_disableResetIdleForTest_default() { + await run_test_disableResetIdleForTest(); +}); + +// Verify that disableResetIdleForTest:true is honoured and terminating +// a background while a listener is still running is enforced: The +// background should be terminated and the listener should become primed. +add_task(async function test_disableResetIdleForTest_true() { + await run_test_disableResetIdleForTest({ disableResetIdleForTest: true }); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_extension_page_tab_navigated.js b/toolkit/components/extensions/test/browser/browser_ext_extension_page_tab_navigated.js new file mode 100644 index 0000000000..9742d42b2e --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_extension_page_tab_navigated.js @@ -0,0 +1,226 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +// The test tasks in this test file tends to trigger an intermittent +// exception raised from JSActor::AfterDestroy, because of a race between +// when the WebExtensions API event is being emitted from the parent process +// and the navigation triggered on the test extension pages. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Actor 'Conduits' destroyed before query 'RunListener' was resolved/ +); + +AddonTestUtils.initMochitest(this); + +const server = AddonTestUtils.createHttpServer({ + hosts: ["example.com", "anotherwebpage.org"], +}); + +server.registerPathHandler("/", (request, response) => { + response.write(` + + + + test webpage + + + `); +}); + +function createTestExtPage({ script }) { + return ` + + + + + + + `; +} + +function createTestExtPageScript(name) { + return `(${function (pageName) { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.log( + `Extension page "${pageName}" got a webRequest event: ${details.url}` + ); + browser.test.sendMessage(`event-received:${pageName}`); + }, + { types: ["main_frame"], urls: ["http://example.com/*"] } + ); + /* eslint-disable mozilla/balanced-listeners */ + window.addEventListener("pageshow", () => { + browser.test.log(`Extension page "${pageName}" got a pageshow event`); + browser.test.sendMessage(`pageshow:${pageName}`); + }); + window.addEventListener("pagehide", () => { + browser.test.log(`Extension page "${pageName}" got a pagehide event`); + browser.test.sendMessage(`pagehide:${pageName}`); + }); + /* eslint-enable mozilla/balanced-listeners */ + }})("${name}");`; +} + +// Triggers a WebRequest listener registered by the test extensions by +// opening a tab on the given web page URL and then closing it after +// it did load. +async function triggerWebRequestListener(webPageURL, pause) { + let webPageTab = await BrowserTestUtils.openNewForegroundTab( + { + gBrowser, + url: webPageURL, + }, + true /* waitForLoad */, + true /* waitForStop */ + ); + BrowserTestUtils.removeTab(webPageTab); +} + +// The following tests tasks are testing the expected behaviors related to same-process and cross-process +// navigations for an extension page, similarly to test_ext_extension_page_navigated.js, but unlike its +// xpcshell counterpart this tests are only testing that after navigating back to an extension page +// previously stored in the BFCache the WebExtensions events subscribed are being received as expected. + +add_task(async function test_extension_page_sameprocess_navigation() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "http://example.com/*"], + }, + files: { + "extpage1.html": createTestExtPage({ script: "extpage1.js" }), + "extpage1.js": createTestExtPageScript("extpage1"), + "extpage2.html": createTestExtPage({ script: "extpage2.js" }), + "extpage2.js": createTestExtPageScript("extpage2"), + }, + }); + + await extension.startup(); + + const policy = WebExtensionPolicy.getByID(extension.id); + + const extPageURL1 = policy.extension.baseURI.resolve("extpage1.html"); + const extPageURL2 = policy.extension.baseURI.resolve("extpage2.html"); + + info("Opening extension page in a new tab"); + const extPageTab = await BrowserTestUtils.addTab(gBrowser, extPageURL1); + let browser = gBrowser.getBrowserForTab(extPageTab); + info("Wait for the extension page to be loaded"); + await extension.awaitMessage("pageshow:extpage1"); + + await triggerWebRequestListener("http://example.com"); + await extension.awaitMessage("event-received:extpage1"); + ok(true, "extpage1 got a webRequest event as expected"); + + info("Load a second extension page in the same tab"); + BrowserTestUtils.startLoadingURIString(browser, extPageURL2); + + info("Wait extpage1 to receive a pagehide event"); + await extension.awaitMessage("pagehide:extpage1"); + info("Wait extpage2 to receive a pageshow event"); + await extension.awaitMessage("pageshow:extpage2"); + + info( + "Trigger a web request event and expect extpage2 to be the only one receiving it" + ); + await triggerWebRequestListener("http://example.com"); + await extension.awaitMessage("event-received:extpage2"); + ok(true, "extpage2 got a webRequest event as expected"); + + info( + "Navigating back to extpage1 and expect extpage2 to be the only one receiving the webRequest event" + ); + + browser.goBack(); + info("Wait for extpage1 to receive a pageshow event"); + await extension.awaitMessage("pageshow:extpage1"); + info("Wait for extpage2 to receive a pagehide event"); + await extension.awaitMessage("pagehide:extpage2"); + + // We only expect extpage1 to be able to receive API events. + await triggerWebRequestListener("http://example.com"); + await extension.awaitMessage("event-received:extpage1"); + ok(true, "extpage1 got a webRequest event as expected"); + + BrowserTestUtils.removeTab(extPageTab); + await extension.awaitMessage("pagehide:extpage1"); + + await extension.unload(); +}); + +add_task(async function test_extension_page_context_navigated_to_web_page() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "http://example.com/*"], + }, + files: { + "extpage.html": createTestExtPage({ script: "extpage.js" }), + "extpage.js": createTestExtPageScript("extpage"), + }, + }); + + await extension.startup(); + + const policy = WebExtensionPolicy.getByID(extension.id); + + const extPageURL = policy.extension.baseURI.resolve("extpage.html"); + // NOTE: this test will navigate the extension page to a webpage url that + // isn't matching the match pattern the test extension is going to use + // in its webRequest event listener, otherwise the extension page being + // navigated will be intermittently able to receive an event before it + // is navigated to the webpage url (and moved into the BFCache or destroyed) + // and trigger an intermittent failure of this test. + const webPageURL = "http://anotherwebpage.org/"; + const triggerWebRequestURL = "http://example.com/"; + + info("Opening extension page in a new tab"); + const extPageTab1 = await BrowserTestUtils.addTab(gBrowser, extPageURL); + let browserForTab1 = gBrowser.getBrowserForTab(extPageTab1); + info("Wait for the extension page to be loaded"); + await extension.awaitMessage("pageshow:extpage"); + + info("Navigate the tab from the extension page to a web page"); + let promiseLoaded = BrowserTestUtils.browserLoaded( + browserForTab1, + false, + webPageURL + ); + BrowserTestUtils.startLoadingURIString(browserForTab1, webPageURL); + info("Wait the tab to have loaded the new webpage url"); + await promiseLoaded; + info("Wait the extension page to receive a pagehide event"); + await extension.awaitMessage("pagehide:extpage"); + + // Trigger a webRequest listener, the extension page is expected to + // not be active, if that isn't the case a test message will be queued + // and will trigger an explicit test failure. + await triggerWebRequestListener(triggerWebRequestURL); + + info("Navigate back to the extension page"); + browserForTab1.goBack(); + info("Wait for extension page to receive a pageshow event"); + await extension.awaitMessage("pageshow:extpage"); + + await triggerWebRequestListener(triggerWebRequestURL); + await extension.awaitMessage("event-received:extpage"); + ok( + true, + "extpage got a webRequest event as expected after being restored from BFCache" + ); + + info("Cleanup and exit test"); + BrowserTestUtils.removeTab(extPageTab1); + + info("Wait the extension page to receive a pagehide event"); + await extension.awaitMessage("pagehide:extpage"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_management_themes.js b/toolkit/components/extensions/test/browser/browser_ext_management_themes.js new file mode 100644 index 0000000000..d3cfa536b8 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_management_themes.js @@ -0,0 +1,177 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +const { BuiltInThemes } = ChromeUtils.importESModule( + "resource:///modules/BuiltInThemes.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Message manager disconnected/ +); + +add_task(async function test_management_themes() { + await BuiltInThemes.ensureBuiltInThemes(); + + const TEST_ID = "test_management_themes@tests.mozilla.com"; + + let theme = ExtensionTestUtils.loadExtension({ + manifest: { + name: "Simple theme test", + version: "1.0", + description: "test theme", + theme: { + images: { + theme_frame: "image1.png", + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + useAddonManager: "temporary", + }); + + async function background(TEST_ID) { + browser.management.onInstalled.addListener(info => { + if (info.name == TEST_ID) { + return; + } + browser.test.log(`${info.name} was installed`); + browser.test.assertEq(info.type, "theme", "addon is theme"); + browser.test.sendMessage("onInstalled", info.name); + }); + browser.management.onDisabled.addListener(info => { + browser.test.log(`${info.name} was disabled`); + browser.test.assertEq(info.type, "theme", "addon is theme"); + browser.test.sendMessage("onDisabled", info.name); + }); + browser.management.onEnabled.addListener(info => { + browser.test.log(`${info.name} was enabled`); + browser.test.assertEq(info.type, "theme", "addon is theme"); + browser.test.sendMessage("onEnabled", info.name); + }); + browser.management.onUninstalled.addListener(info => { + browser.test.log(`${info.name} was uninstalled`); + browser.test.assertEq(info.type, "theme", "addon is theme"); + browser.test.sendMessage("onUninstalled", info.name); + }); + + async function getAddon(type) { + let addons = await browser.management.getAll(); + let themes = addons.filter(addon => addon.type === "theme"); + const STANDARD_BUILTIN_THEME_IDS = [ + "default-theme@mozilla.org", + "firefox-compact-light@mozilla.org", + "firefox-compact-dark@mozilla.org", + "firefox-alpenglow@mozilla.org", + ]; + // Check that management.getAll returns the built-in themes and our test + // extension. + for (let id of [...STANDARD_BUILTIN_THEME_IDS, TEST_ID]) { + let builtInExtension = addons.find(addon => { + return addon.id === id; + }); + browser.test.assertTrue( + !!builtInExtension, + `The extension with id ${id} was returned by getAll.` + ); + } + let found; + for (let addon of themes) { + browser.test.assertEq(addon.type, "theme", "addon is theme"); + if (type == "theme" && addon.id.includes("temporary-addon")) { + found = addon; + } else if (type == "enabled" && addon.enabled) { + found = addon; + } + } + return found; + } + + browser.test.onMessage.addListener(async msg => { + let theme = await getAddon("theme"); + browser.test.assertEq( + theme.description, + "test theme", + "description is correct" + ); + browser.test.assertTrue(theme.enabled, "theme is enabled"); + await browser.management.setEnabled(theme.id, false); + + theme = await getAddon("theme"); + + browser.test.assertTrue(!theme.enabled, "theme is disabled"); + let addon = getAddon("enabled"); + browser.test.assertTrue(addon, "another theme was enabled"); + + await browser.management.setEnabled(theme.id, true); + theme = await getAddon("theme"); + addon = await getAddon("enabled"); + browser.test.assertEq(theme.id, addon.id, "theme is enabled"); + + browser.test.sendMessage("done"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: TEST_ID, + }, + }, + name: TEST_ID, + permissions: ["management"], + }, + background: `(${background})("${TEST_ID}")`, + useAddonManager: "temporary", + }); + await extension.startup(); + + await theme.startup(); + is( + await extension.awaitMessage("onInstalled"), + "Simple theme test", + "webextension theme installed" + ); + is( + await extension.awaitMessage("onDisabled"), + "System theme — auto", + "default disabled" + ); + + extension.sendMessage("test"); + is( + await extension.awaitMessage("onEnabled"), + "System theme — auto", + "default enabled" + ); + is( + await extension.awaitMessage("onDisabled"), + "Simple theme test", + "addon disabled" + ); + is( + await extension.awaitMessage("onEnabled"), + "Simple theme test", + "addon enabled" + ); + is( + await extension.awaitMessage("onDisabled"), + "System theme — auto", + "default disabled" + ); + await extension.awaitMessage("done"); + + await Promise.all([theme.unload(), extension.awaitMessage("onUninstalled")]); + + is( + await extension.awaitMessage("onEnabled"), + "System theme — auto", + "default enabled" + ); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_process_crash_handling.js b/toolkit/components/extensions/test/browser/browser_ext_process_crash_handling.js new file mode 100644 index 0000000000..c33727b96b --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_process_crash_handling.js @@ -0,0 +1,180 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +const { ExtensionProcessCrashObserver, Management } = + ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + +AddonTestUtils.initMochitest(this); + +add_task(async function test_ExtensionProcessCrashObserver() { + await SpecialPowers.pushPrefEnv({ + // This test triggers a crash and so it will be restarting all builtin + // extensions persistent background pages as a side effect (and that would + // be make this test to hit failures due to the builtin background pages + // still in the process of being restarted being detected as shutdown leaks). + set: [["extensions.background.disableRestartPersistentAfterCrash", true]], + }); + let mv2Extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + manifest_version: 2, + }, + background() { + browser.test.sendMessage("background_running"); + }, + }); + + await mv2Extension.startup(); + await mv2Extension.awaitMessage("background_running"); + + let { + currentProcessChildID, + lastCrashedProcessChildID, + processSpawningDisabled, + lastCrashTimestamps, + } = ExtensionProcessCrashObserver; + + Assert.notEqual( + currentProcessChildID, + undefined, + "Expect ExtensionProcessCrashObserver.currentProcessChildID to be set" + ); + + Assert.equal( + ChromeUtils.getAllDOMProcesses().find( + pp => pp.childID == currentProcessChildID + )?.remoteType, + "extension", + "Expect a child process with remoteType extension to be found for the process childID set" + ); + + Assert.notEqual( + lastCrashedProcessChildID, + currentProcessChildID, + "Expect lastCrashedProcessChildID to not be set to the same value that currentProcessChildID is set" + ); + + Assert.equal( + processSpawningDisabled, + false, + "Expect process spawning to be enabled" + ); + + Assert.deepEqual(lastCrashTimestamps, [], "Expect no crash timestamps"); + + let mv3Extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + manifest_version: 3, + }, + background() { + browser.test.sendMessage("background_running"); + }, + }); + + const waitForExtensionBrowserInserted = () => + new Promise(resolve => { + const listener = (_eventName, browser) => { + if (!browser.getAttribute("webextension-view-type") === "background") { + return; + } + Management.off("extension-browser-inserted", listener); + resolve(browser); + }; + Management.on("extension-browser-inserted", listener); + }); + + const waitForExtensionProcessCrashNotified = () => + new Promise(resolve => { + Management.once("extension-process-crash", (_evt, data) => resolve(data)); + }); + + const promiseBackgroundBrowser = waitForExtensionBrowserInserted(); + + const promiseExtensionProcessCrashNotified = + waitForExtensionProcessCrashNotified(); + + await mv3Extension.startup(); + await mv3Extension.awaitMessage("background_running"); + const bgPageBrowser = await promiseBackgroundBrowser; + + Assert.ok( + Glean.extensions.processEvent.created_fg.testGetValue() > 0, + "Expect glean processEvent.created_fg to be set." + ); + Assert.equal( + undefined, + Glean.extensions.processEvent.created_bg.testGetValue(), + "Creating in the background is not expected on desktop." + ); + + info("Force extension process crash"); + // Clear any existing telemetry data, so that we can be sure we can + // assert the glean process_event metric labels values to be strictly + // equal to 1 after the extension process crashed. + Services.fog.testResetFOG(); + // NOTE: shouldShowTabCrashPage option needs to be set to false + // to make sure crashFrame method resolves without waiting for a + // tab crash page (which is not going to be shown for a background + // page browser element). + await BrowserTestUtils.crashFrame( + bgPageBrowser, + /* shouldShowTabCrashPage */ false + ); + + info("Verify ExtensionProcessCrashObserver after extension process crash"); + Assert.equal( + ExtensionProcessCrashObserver.lastCrashedProcessChildID, + currentProcessChildID, + "Expect ExtensionProcessCrashObserver.lastCrashedProcessChildID to be set to the expected childID" + ); + + Assert.equal( + ExtensionProcessCrashObserver.processSpawningDisabled, + false, + "Expect process spawning to still be enabled" + ); + Assert.equal( + ExtensionProcessCrashObserver.lastCrashTimestamps.length, + 1, + "Expect a crash timestamp" + ); + + info("Expect the same childID to have been notified as a Management event"); + Assert.deepEqual( + await promiseExtensionProcessCrashNotified, + { + childID: currentProcessChildID, + processSpawningDisabled: false, + // This boolean flag is expected to be always true on Desktop builds. + appInForeground: true, + }, + "Got the expected childID notified as part of the extension-process-crash Management event" + ); + + Assert.ok( + Glean.extensions.processEvent.crashed_fg.testGetValue() > 0, + "Expect glean processEvent.crashed_fg to be set" + ); + Assert.equal( + undefined, + Glean.extensions.processEvent.crashed_bg.testGetValue(), + "Crashing in the background is not expected on desktop." + ); + + info("Wait for mv3 extension shutdown"); + await mv3Extension.unload(); + info("Wait for mv2 extension shutdown"); + await mv2Extension.unload(); + + // Reset this array to prevent TV failures. + ExtensionProcessCrashObserver.lastCrashTimestamps = []; + + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_test_mock.js b/toolkit/components/extensions/test/browser/browser_ext_test_mock.js new file mode 100644 index 0000000000..de496b7631 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_test_mock.js @@ -0,0 +1,47 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// This test verifies that the extension mocks behave consistently, regardless +// of test type (xpcshell vs browser test). +// See also toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js + +// Check the state of the extension object. This should be consistent between +// browser tests and xpcshell tests. +async function checkExtensionStartupAndUnload(ext) { + await ext.startup(); + Assert.ok(ext.id, "Extension ID should be available"); + Assert.ok(ext.uuid, "Extension UUID should be available"); + await ext.unload(); + // Once set nothing clears the UUID. + Assert.ok(ext.uuid, "Extension UUID exists after unload"); +} + +add_task(async function test_MockExtension() { + // When "useAddonManager" is set, a MockExtension is created in the main + // process, which does not necessarily behave identically to an Extension. + let ext = ExtensionTestUtils.loadExtension({ + // xpcshell/test_ext_test_mock.js tests "temporary", so here we use + // "permanent" to have even more test coverage. + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "@permanent-mock-extension" } }, + }, + }); + + Assert.ok(!ext.id, "Extension ID is initially unavailable"); + Assert.ok(!ext.uuid, "Extension UUID is initially unavailable"); + await checkExtensionStartupAndUnload(ext); + Assert.ok(ext.id, "Extension ID exists after unload"); +}); + +add_task(async function test_generated_Extension() { + let ext = ExtensionTestUtils.loadExtension({ + manifest: {}, + }); + + Assert.ok(!ext.id, "Extension ID is initially unavailable"); + Assert.ok(!ext.uuid, "Extension UUID is initially unavailable"); + await checkExtensionStartupAndUnload(ext); + Assert.ok(ext.id, "Extension ID exists after unload"); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_additional_backgrounds_alignment.js b/toolkit/components/extensions/test/browser/browser_ext_themes_additional_backgrounds_alignment.js new file mode 100644 index 0000000000..b02e552ecb --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_additional_backgrounds_alignment.js @@ -0,0 +1,88 @@ +"use strict"; + +// Case 1 - When there is a theme_frame image and additional_backgrounds_alignment is not specified. +// So background-position should default to "right top" +add_task(async function test_default_additional_backgrounds_alignment() { + const RIGHT_TOP = "100% 0%"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + additional_backgrounds: ["image1.png", "image1.png"], + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let toolbox = document.querySelector("#navigator-toolbox"); + let toolboxCS = window.getComputedStyle(toolbox); + /** + * We expect duplicate background-position values because we apply `right top` + * once for theme_frame, and again as the default value of + * --lwt-background-alignment. + */ + Assert.equal( + toolboxCS.getPropertyValue("background-position"), + `${RIGHT_TOP}, ${RIGHT_TOP}`, + toolbox.id + + " contains theme_frame and default lwt-background-alignment properties" + ); + + await extension.unload(); +}); + +// Case 2 - When there is a theme_frame image and additional_backgrounds_alignment is specified. +add_task(async function test_additional_backgrounds_alignment() { + const LEFT_BOTTOM = "0% 100%"; + const CENTER_CENTER = "50% 50%"; + const RIGHT_TOP = "100% 0%"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + additional_backgrounds: ["image1.png", "image1.png", "image1.png"], + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + }, + properties: { + additional_backgrounds_alignment: [ + "left bottom", + "center center", + "right top", + ], + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let toolbox = document.querySelector("#navigator-toolbox"); + let toolboxCS = window.getComputedStyle(toolbox); + Assert.equal( + toolboxCS.getPropertyValue("background-position"), + RIGHT_TOP + ", " + LEFT_BOTTOM + ", " + CENTER_CENTER + ", " + RIGHT_TOP, + toolbox.id + + " contains theme_frame and additional_backgrounds alignment properties" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_alpha_accentcolor.js b/toolkit/components/extensions/test/browser/browser_ext_themes_alpha_accentcolor.js new file mode 100644 index 0000000000..761e89561f --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_alpha_accentcolor.js @@ -0,0 +1,30 @@ +"use strict"; + +add_task(async function test_alpha_frame_color() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: "rgba(230, 128, 0, 0.1)", + tab_background_text: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + Assert.equal( + getToolboxBackgroundColor(), + "rgb(230, 128, 0)", + "Window background color should be opaque" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_arrowpanels.js b/toolkit/components/extensions/test/browser/browser_ext_themes_arrowpanels.js new file mode 100644 index 0000000000..6665fb3092 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_arrowpanels.js @@ -0,0 +1,82 @@ +"use strict"; + +function openIdentityPopup() { + let promise = BrowserTestUtils.waitForEvent( + window, + "popupshown", + true, + event => event.target == gIdentityHandler._identityPopup + ); + gIdentityHandler._identityIconBox.click(); + return promise; +} + +function closeIdentityPopup() { + let promise = BrowserTestUtils.waitForEvent( + gIdentityHandler._identityPopup, + "popuphidden" + ); + gIdentityHandler._identityPopup.hidePopup(); + return promise; +} + +// This test checks applied WebExtension themes that attempt to change +// popup properties + +add_task(async function test_popup_styling(browser, accDoc) { + const POPUP_BACKGROUND_COLOR = "#FF0000"; + const POPUP_TEXT_COLOR = "#008000"; + const POPUP_BORDER_COLOR = "#0000FF"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + popup: POPUP_BACKGROUND_COLOR, + popup_text: POPUP_TEXT_COLOR, + popup_border: POPUP_BORDER_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "https://example.com" }, + async function (browser) { + await extension.startup(); + + // Open the information arrow panel + await openIdentityPopup(); + + let arrowContent = gIdentityHandler._identityPopup.panelContent; + let arrowContentComputedStyle = window.getComputedStyle(arrowContent); + // Ensure popup background color was set properly + Assert.equal( + arrowContentComputedStyle.getPropertyValue("background-color"), + `rgb(${hexToRGB(POPUP_BACKGROUND_COLOR).join(", ")})`, + "Popup background color should have been themed" + ); + + // Ensure popup text color was set properly + Assert.equal( + arrowContentComputedStyle.getPropertyValue("color"), + `rgb(${hexToRGB(POPUP_TEXT_COLOR).join(", ")})`, + "Popup text color should have been themed" + ); + + // Ensure popup border color was set properly + testBorderColor(arrowContent, POPUP_BORDER_COLOR); + + await closeIdentityPopup(); + await extension.unload(); + } + ); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_autocomplete_popup.js b/toolkit/components/extensions/test/browser/browser_ext_themes_autocomplete_popup.js new file mode 100644 index 0000000000..d2baf6157b --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_autocomplete_popup.js @@ -0,0 +1,173 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// popup properties are applied correctly to the autocomplete bar. +const POPUP_COLOR_DARK = "#00A400"; +const POPUP_COLOR_BRIGHT = "#85A4FF"; +const POPUP_TEXT_COLOR_DARK = "#000000"; +const POPUP_TEXT_COLOR_BRIGHT = "#ffffff"; +const POPUP_SELECTED_COLOR = "#9400ff"; +const POPUP_SELECTED_TEXT_COLOR = "#09b9a6"; + +const POPUP_URL_COLOR_DARK = "#0061e0"; +const POPUP_ACTION_COLOR_DARK = "#5b5b66"; +const POPUP_URL_COLOR_BRIGHT = "#00ddff"; +const POPUP_ACTION_COLOR_BRIGHT = "#bfbfc9"; + +const SEARCH_TERM = "urlbar-reflows-" + Date.now(); + +ChromeUtils.defineESModuleGetters(this, { + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +add_setup(async function () { + await PlacesUtils.history.clear(); + const NUM_VISITS = 10; + let visits = []; + + for (let i = 0; i < NUM_VISITS; ++i) { + visits.push({ + uri: `http://example.com/urlbar-reflows-${i}`, + title: `Reflow test for URL bar entry #${i} - ${SEARCH_TERM}`, + }); + } + + await PlacesTestUtils.addVisits(visits); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function test_popup_url() { + // Load a manifest with popup_text being dark (bright background). Test for + // dark text properties. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field_focus: POPUP_COLOR_BRIGHT, + toolbar_field_text_focus: POPUP_TEXT_COLOR_DARK, + popup_highlight: POPUP_SELECTED_COLOR, + popup_highlight_text: POPUP_SELECTED_TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let maxResults = Services.prefs.getIntPref("browser.urlbar.maxRichResults"); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla" + ); + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + await BrowserTestUtils.removeTab(tab); + }); + + let visits = []; + + for (let i = 0; i < maxResults; i++) { + visits.push({ uri: makeURI("http://example.com/autocomplete/?" + i) }); + } + + await PlacesTestUtils.addVisits(visits); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: "example.com/autocomplete", + }); + await UrlbarTestUtils.waitForAutocompleteResultAt(window, maxResults - 1); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + maxResults, + "Should get maxResults=" + maxResults + " results" + ); + + // Set the selected attribute to true to test the highlight popup properties + UrlbarTestUtils.setSelectedRowIndex(window, 1); + let actionResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + let urlResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + let resultCS = window.getComputedStyle(urlResult.element.row); + + Assert.equal( + resultCS.backgroundColor, + `rgb(${hexToRGB(POPUP_SELECTED_COLOR).join(", ")})`, + `Popup highlight background color should be set to ${POPUP_SELECTED_COLOR}` + ); + + Assert.equal( + resultCS.color, + `rgb(${hexToRGB(POPUP_SELECTED_TEXT_COLOR).join(", ")})`, + `Popup highlight color should be set to ${POPUP_SELECTED_TEXT_COLOR}` + ); + + // Now set the index to somewhere not on the first two, so that we can test both + // url and action text colors. + UrlbarTestUtils.setSelectedRowIndex(window, 2); + + Assert.equal( + window.getComputedStyle(urlResult.element.url).color, + `rgb(${hexToRGB(POPUP_URL_COLOR_DARK).join(", ")})`, + `Urlbar popup url color should be set to ${POPUP_URL_COLOR_DARK}` + ); + + Assert.equal( + window.getComputedStyle(actionResult.element.action).color, + `rgb(${hexToRGB(POPUP_ACTION_COLOR_DARK).join(", ")})`, + `Urlbar popup action color should be set to ${POPUP_ACTION_COLOR_DARK}` + ); + + await extension.unload(); + + // Load a manifest with popup_text being bright (dark background). Test for + // bright text properties. + extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field_focus: POPUP_COLOR_DARK, + toolbar_field_text_focus: POPUP_TEXT_COLOR_BRIGHT, + popup_highlight: POPUP_SELECTED_COLOR, + popup_highlight_text: POPUP_SELECTED_TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + Assert.equal( + window.getComputedStyle(urlResult.element.url).color, + `rgb(${hexToRGB(POPUP_URL_COLOR_BRIGHT).join(", ")})`, + `Urlbar popup url color should be set to ${POPUP_URL_COLOR_BRIGHT}` + ); + + Assert.equal( + window.getComputedStyle(actionResult.element.action).color, + `rgb(${hexToRGB(POPUP_ACTION_COLOR_BRIGHT).join(", ")})`, + `Urlbar popup action color should be set to ${POPUP_ACTION_COLOR_BRIGHT}` + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_chromeparity.js b/toolkit/components/extensions/test/browser/browser_ext_themes_chromeparity.js new file mode 100644 index 0000000000..086ea39653 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_chromeparity.js @@ -0,0 +1,159 @@ +"use strict"; + +add_task(async function test_support_theme_frame() { + const FRAME_COLOR = [71, 105, 91]; + const TAB_TEXT_COLOR = [0, 0, 0]; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "face.png", + }, + colors: { + frame: FRAME_COLOR, + tab_background_text: TAB_TEXT_COLOR, + }, + }, + }, + files: { + "face.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + + Assert.ok( + docEl.hasAttribute("lwtheme-image"), + "LWT image attribute should be set" + ); + + Assert.equal( + docEl.getAttribute("lwtheme-brighttext"), + null, + "LWT text color attribute should not be set" + ); + + let toolbox = document.querySelector("#navigator-toolbox"); + let toolboxCS = window.getComputedStyle(toolbox); + + Assert.ok( + toolboxCS.backgroundImage.includes("face.png"), + `The backgroundImage should use face.png. Actual value is: ${toolboxCS.backgroundImage}` + ); + Assert.equal( + getToolboxBackgroundColor(), + "rgb(" + FRAME_COLOR.join(", ") + ")", + "Expected correct background color" + ); + Assert.equal( + toolboxCS.color, + "rgb(" + TAB_TEXT_COLOR.join(", ") + ")", + "Expected correct text color" + ); + + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); + + Assert.ok( + !docEl.hasAttribute("lwtheme-image"), + "LWT image attribute should not be set" + ); + + Assert.ok( + !docEl.hasAttribute("lwtheme-brighttext"), + "LWT text color attribute should not be set" + ); +}); + +add_task(async function test_support_theme_frame_inactive() { + const FRAME_COLOR = [71, 105, 91]; + const FRAME_COLOR_INACTIVE = [255, 0, 0]; + const TAB_TEXT_COLOR = [207, 221, 192]; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: FRAME_COLOR, + frame_inactive: FRAME_COLOR_INACTIVE, + tab_background_text: TAB_TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + + Assert.equal( + getToolboxBackgroundColor(), + "rgb(" + FRAME_COLOR.join(", ") + ")", + "Window background is set to the colors.frame property" + ); + + // Now we'll open a new window to see if the inactive browser accent color changed + let window2 = await BrowserTestUtils.openNewBrowserWindow(); + Assert.equal( + getToolboxBackgroundColor(), + "rgb(" + FRAME_COLOR_INACTIVE.join(", ") + ")", + `Inactive window background color should be ${FRAME_COLOR_INACTIVE}` + ); + + await BrowserTestUtils.closeWindow(window2); + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); + +add_task(async function test_lack_of_theme_frame_inactive() { + const FRAME_COLOR = [71, 105, 91]; + const TAB_TEXT_COLOR = [207, 221, 192]; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: FRAME_COLOR, + tab_background_text: TAB_TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + Assert.equal( + getToolboxBackgroundColor(), + "rgb(" + FRAME_COLOR.join(", ") + ")", + "Window background is set to the colors.frame property" + ); + + // Now we'll open a new window to make sure the inactive browser accent color stayed the same + let window2 = await BrowserTestUtils.openNewBrowserWindow(); + Assert.equal( + getToolboxBackgroundColor(), + "rgb(" + FRAME_COLOR.join(", ") + ")", + "Inactive window background should not change if colors.frame_inactive isn't set" + ); + + await BrowserTestUtils.closeWindow(window2); + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_getCurrent.js b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_getCurrent.js new file mode 100644 index 0000000000..4a379edfbf --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_getCurrent.js @@ -0,0 +1,203 @@ +"use strict"; + +// This test checks whether browser.theme.getCurrent() works correctly in different +// configurations and with different parameter. + +// PNG image data for a simple red dot. +const BACKGROUND_1 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; +// PNG image data for the Mozilla dino head. +const BACKGROUND_2 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="; + +add_task(async function test_get_current() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + const ACCENT_COLOR_1 = "#a14040"; + const TEXT_COLOR_1 = "#fac96e"; + + const ACCENT_COLOR_2 = "#03fe03"; + const TEXT_COLOR_2 = "#0ef325"; + + const theme1 = { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR_1, + tab_background_text: TEXT_COLOR_1, + }, + }; + + const theme2 = { + images: { + theme_frame: "image2.png", + }, + colors: { + frame: ACCENT_COLOR_2, + tab_background_text: TEXT_COLOR_2, + }, + }; + + function ensureWindowFocused(winId) { + browser.test.log("Waiting for focused window to be " + winId); + // eslint-disable-next-line no-async-promise-executor + return new Promise(async resolve => { + let listener = windowId => { + if (windowId === winId) { + browser.windows.onFocusChanged.removeListener(listener); + resolve(); + } + }; + // We first add a listener and then check whether the window is + // focused using .get(), because the .get() Promise resolving + // could race with the listener running, in which case we'd + // never be notified. + browser.windows.onFocusChanged.addListener(listener); + let { focused } = await browser.windows.get(winId); + if (focused) { + browser.windows.onFocusChanged.removeListener(listener); + resolve(); + } + }); + } + + function testTheme1(returnedTheme) { + browser.test.assertTrue( + returnedTheme.images.theme_frame.includes("image1.png"), + "Theme 1 theme_frame image should be applied" + ); + browser.test.assertEq( + ACCENT_COLOR_1, + returnedTheme.colors.frame, + "Theme 1 frame color should be applied" + ); + browser.test.assertEq( + TEXT_COLOR_1, + returnedTheme.colors.tab_background_text, + "Theme 1 tab_background_text color should be applied" + ); + } + + function testTheme2(returnedTheme) { + browser.test.assertTrue( + returnedTheme.images.theme_frame.includes("image2.png"), + "Theme 2 theme_frame image should be applied" + ); + browser.test.assertEq( + ACCENT_COLOR_2, + returnedTheme.colors.frame, + "Theme 2 frame color should be applied" + ); + browser.test.assertEq( + TEXT_COLOR_2, + returnedTheme.colors.tab_background_text, + "Theme 2 tab_background_text color should be applied" + ); + } + + function testEmptyTheme(returnedTheme) { + browser.test.assertEq( + JSON.stringify({ colors: null, images: null, properties: null }), + JSON.stringify(returnedTheme), + JSON.stringify(returnedTheme, null, 2) + ); + } + + browser.test.log("Testing getCurrent() with initial unthemed window"); + const firstWin = await browser.windows.getCurrent(); + testEmptyTheme(await browser.theme.getCurrent()); + testEmptyTheme(await browser.theme.getCurrent(firstWin.id)); + + browser.test.log("Testing getCurrent() with after theme.update()"); + await browser.theme.update(theme1); + testTheme1(await browser.theme.getCurrent()); + testTheme1(await browser.theme.getCurrent(firstWin.id)); + + browser.test.log( + "Testing getCurrent() with after theme.update(windowId)" + ); + const secondWin = await browser.windows.create(); + await ensureWindowFocused(secondWin.id); + await browser.theme.update(secondWin.id, theme2); + testTheme2(await browser.theme.getCurrent()); + testTheme1(await browser.theme.getCurrent(firstWin.id)); + testTheme2(await browser.theme.getCurrent(secondWin.id)); + + browser.test.log("Testing getCurrent() after window focus change"); + let focusChanged = ensureWindowFocused(firstWin.id); + await browser.windows.update(firstWin.id, { focused: true }); + await focusChanged; + testTheme1(await browser.theme.getCurrent()); + testTheme1(await browser.theme.getCurrent(firstWin.id)); + testTheme2(await browser.theme.getCurrent(secondWin.id)); + + browser.test.log( + "Testing getCurrent() after another window focus change" + ); + focusChanged = ensureWindowFocused(secondWin.id); + await browser.windows.update(secondWin.id, { focused: true }); + await focusChanged; + testTheme2(await browser.theme.getCurrent()); + testTheme1(await browser.theme.getCurrent(firstWin.id)); + testTheme2(await browser.theme.getCurrent(secondWin.id)); + + browser.test.log("Testing getCurrent() after theme.reset(windowId)"); + await browser.theme.reset(firstWin.id); + testTheme2(await browser.theme.getCurrent()); + testTheme1(await browser.theme.getCurrent(firstWin.id)); + testTheme2(await browser.theme.getCurrent(secondWin.id)); + + browser.test.log( + "Testing getCurrent() after reset and window focus change" + ); + focusChanged = ensureWindowFocused(firstWin.id); + await browser.windows.update(firstWin.id, { focused: true }); + await focusChanged; + testTheme1(await browser.theme.getCurrent()); + testTheme1(await browser.theme.getCurrent(firstWin.id)); + testTheme2(await browser.theme.getCurrent(secondWin.id)); + + browser.test.log("Testing getCurrent() after theme.update(windowId)"); + await browser.theme.update(firstWin.id, theme1); + testTheme1(await browser.theme.getCurrent()); + testTheme1(await browser.theme.getCurrent(firstWin.id)); + testTheme2(await browser.theme.getCurrent(secondWin.id)); + + browser.test.log("Testing getCurrent() after theme.reset()"); + await browser.theme.reset(); + testEmptyTheme(await browser.theme.getCurrent()); + testEmptyTheme(await browser.theme.getCurrent(firstWin.id)); + testEmptyTheme(await browser.theme.getCurrent(secondWin.id)); + + browser.test.log("Testing getCurrent() after closing a window"); + await browser.windows.remove(secondWin.id); + testEmptyTheme(await browser.theme.getCurrent()); + testEmptyTheme(await browser.theme.getCurrent(firstWin.id)); + + browser.test.log("Testing update calls with invalid window ID"); + await browser.test.assertRejects( + browser.theme.reset(secondWin.id), + /Invalid window/, + "Invalid window should throw" + ); + await browser.test.assertRejects( + browser.theme.update(secondWin.id, theme2), + /Invalid window/, + "Invalid window should throw" + ); + browser.test.notifyPass("get_current"); + }, + manifest: { + permissions: ["theme"], + }, + files: { + "image1.png": BACKGROUND_1, + "image2.png": BACKGROUND_2, + }, + }); + + await extension.startup(); + await extension.awaitFinish("get_current"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_onUpdated.js b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_onUpdated.js new file mode 100644 index 0000000000..34c7162810 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_onUpdated.js @@ -0,0 +1,154 @@ +"use strict"; + +// This test checks whether browser.theme.onUpdated works correctly with different +// types of dynamic theme updates. + +// PNG image data for a simple red dot. +const BACKGROUND_1 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; +// PNG image data for the Mozilla dino head. +const BACKGROUND_2 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="; + +add_task(async function test_on_updated() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + const ACCENT_COLOR_1 = "#a14040"; + const TEXT_COLOR_1 = "#fac96e"; + + const ACCENT_COLOR_2 = "#03fe03"; + const TEXT_COLOR_2 = "#0ef325"; + + const theme1 = { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR_1, + tab_background_text: TEXT_COLOR_1, + }, + }; + + const theme2 = { + images: { + theme_frame: "image2.png", + }, + colors: { + frame: ACCENT_COLOR_2, + tab_background_text: TEXT_COLOR_2, + }, + }; + + function testTheme1(returnedTheme) { + browser.test.assertTrue( + returnedTheme.images.theme_frame.includes("image1.png"), + "Theme 1 theme_frame image should be applied" + ); + browser.test.assertEq( + ACCENT_COLOR_1, + returnedTheme.colors.frame, + "Theme 1 frame color should be applied" + ); + browser.test.assertEq( + TEXT_COLOR_1, + returnedTheme.colors.tab_background_text, + "Theme 1 tab_background_text color should be applied" + ); + } + + function testTheme2(returnedTheme) { + browser.test.assertTrue( + returnedTheme.images.theme_frame.includes("image2.png"), + "Theme 2 theme_frame image should be applied" + ); + browser.test.assertEq( + ACCENT_COLOR_2, + returnedTheme.colors.frame, + "Theme 2 frame color should be applied" + ); + browser.test.assertEq( + TEXT_COLOR_2, + returnedTheme.colors.tab_background_text, + "Theme 2 tab_background_text color should be applied" + ); + } + + const firstWin = await browser.windows.getCurrent(); + const secondWin = await browser.windows.create(); + + const onceThemeUpdated = () => + new Promise(resolve => { + const listener = updateInfo => { + browser.theme.onUpdated.removeListener(listener); + resolve(updateInfo); + }; + browser.theme.onUpdated.addListener(listener); + }); + + browser.test.log("Testing update with no windowId parameter"); + let updateInfo1 = onceThemeUpdated(); + await browser.theme.update(theme1); + updateInfo1 = await updateInfo1; + testTheme1(updateInfo1.theme); + browser.test.assertTrue( + !updateInfo1.windowId, + "No window id on first update" + ); + + browser.test.log("Testing update with windowId parameter"); + let updateInfo2 = onceThemeUpdated(); + await browser.theme.update(secondWin.id, theme2); + updateInfo2 = await updateInfo2; + testTheme2(updateInfo2.theme); + browser.test.assertEq( + secondWin.id, + updateInfo2.windowId, + "window id on second update" + ); + + browser.test.log("Testing reset with windowId parameter"); + let updateInfo3 = onceThemeUpdated(); + await browser.theme.reset(firstWin.id); + updateInfo3 = await updateInfo3; + browser.test.assertEq( + 0, + Object.keys(updateInfo3.theme).length, + "Empty theme given on reset" + ); + browser.test.assertEq( + firstWin.id, + updateInfo3.windowId, + "window id on third update" + ); + + browser.test.log("Testing reset with no windowId parameter"); + let updateInfo4 = onceThemeUpdated(); + await browser.theme.reset(); + updateInfo4 = await updateInfo4; + browser.test.assertEq( + 0, + Object.keys(updateInfo4.theme).length, + "Empty theme given on reset" + ); + browser.test.assertTrue( + !updateInfo4.windowId, + "no window id on fourth update" + ); + + browser.test.log("Cleaning up test"); + await browser.windows.remove(secondWin.id); + browser.test.notifyPass("onUpdated"); + }, + manifest: { + permissions: ["theme"], + }, + files: { + "image1.png": BACKGROUND_1, + "image2.png": BACKGROUND_2, + }, + }); + + await extension.startup(); + await extension.awaitFinish("onUpdated"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_updates.js b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_updates.js new file mode 100644 index 0000000000..5fae5150b6 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_updates.js @@ -0,0 +1,199 @@ +"use strict"; + +// PNG image data for a simple red dot. +const BACKGROUND_1 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; +const ACCENT_COLOR_1 = "#a14040"; +const TEXT_COLOR_1 = "#fac96e"; + +// PNG image data for the Mozilla dino head. +const BACKGROUND_2 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="; +const ACCENT_COLOR_2 = "#03fe03"; +const TEXT_COLOR_2 = "#0ef325"; + +function hexToRGB(hex) { + hex = parseInt(hex.indexOf("#") > -1 ? hex.substring(1) : hex, 16); + return ( + "rgb(" + [hex >> 16, (hex & 0x00ff00) >> 8, hex & 0x0000ff].join(", ") + ")" + ); +} + +function validateTheme(backgroundImage, accentColor, textColor, isLWT) { + let docEl = window.document.documentElement; + let rootCS = window.getComputedStyle(docEl); + + let toolbox = document.querySelector("#navigator-toolbox"); + let toolboxCS = window.getComputedStyle(toolbox); + + if (isLWT) { + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal( + docEl.getAttribute("lwtheme-brighttext"), + "true", + "LWT text color attribute should be set" + ); + } + + if (accentColor.startsWith("#")) { + accentColor = hexToRGB(accentColor); + } + if (textColor.startsWith("#")) { + textColor = hexToRGB(textColor); + } + Assert.ok( + toolboxCS.backgroundImage.includes(backgroundImage), + "Expected correct background image" + ); + Assert.equal( + getToolboxBackgroundColor(), + accentColor, + "Expected correct accent color" + ); + + Assert.equal(rootCS.color, textColor, "Expected correct text color"); +} + +add_task(async function test_dynamic_theme_updates() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + files: { + "image1.png": BACKGROUND_1, + "image2.png": BACKGROUND_2, + }, + background() { + browser.test.onMessage.addListener((msg, details) => { + if (msg === "update-theme") { + browser.theme.update(details).then(() => { + browser.test.sendMessage("theme-updated"); + }); + } else { + browser.theme.reset().then(() => { + browser.test.sendMessage("theme-reset"); + }); + } + }); + }, + }); + + let rootCS = window.getComputedStyle(window.document.documentElement); + let toolboxCS = window.getComputedStyle( + window.document.documentElement.querySelector("#navigator-toolbox") + ); + await extension.startup(); + + extension.sendMessage("update-theme", { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR_1, + tab_background_text: TEXT_COLOR_1, + }, + }); + + await extension.awaitMessage("theme-updated"); + + validateTheme("image1.png", ACCENT_COLOR_1, TEXT_COLOR_1, true); + + // Check with the LWT aliases (to update on Firefox 69, because the + // LWT aliases are going to be removed). + extension.sendMessage("update-theme", { + images: { + theme_frame: "image2.png", + }, + colors: { + frame: ACCENT_COLOR_2, + tab_background_text: TEXT_COLOR_2, + }, + }); + + await extension.awaitMessage("theme-updated"); + + validateTheme("image2.png", ACCENT_COLOR_2, TEXT_COLOR_2, true); + + extension.sendMessage("reset-theme"); + + await extension.awaitMessage("theme-reset"); + + let { color } = rootCS; + let backgroundImage = toolboxCS.backgroundImage; + let backgroundColor = getToolboxBackgroundColor(); + validateTheme(backgroundImage, backgroundColor, color, false); + + await extension.unload(); + + let docEl = window.document.documentElement; + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); + +add_task(async function test_dynamic_theme_updates_with_data_url() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + background() { + browser.test.onMessage.addListener((msg, details) => { + if (msg === "update-theme") { + browser.theme.update(details).then(() => { + browser.test.sendMessage("theme-updated"); + }); + } else { + browser.theme.reset().then(() => { + browser.test.sendMessage("theme-reset"); + }); + } + }); + }, + }); + + let rootCS = window.getComputedStyle(window.document.documentElement); + let toolboxCS = window.getComputedStyle( + window.document.documentElement.querySelector("#navigator-toolbox") + ); + await extension.startup(); + + extension.sendMessage("update-theme", { + images: { + theme_frame: BACKGROUND_1, + }, + colors: { + frame: ACCENT_COLOR_1, + tab_background_text: TEXT_COLOR_1, + }, + }); + + await extension.awaitMessage("theme-updated"); + + validateTheme(BACKGROUND_1, ACCENT_COLOR_1, TEXT_COLOR_1, true); + + extension.sendMessage("update-theme", { + images: { + theme_frame: BACKGROUND_2, + }, + colors: { + frame: ACCENT_COLOR_2, + tab_background_text: TEXT_COLOR_2, + }, + }); + + await extension.awaitMessage("theme-updated"); + + validateTheme(BACKGROUND_2, ACCENT_COLOR_2, TEXT_COLOR_2, true); + + extension.sendMessage("reset-theme"); + + await extension.awaitMessage("theme-reset"); + + let { color } = rootCS; + let backgroundImage = toolboxCS.backgroundImage; + let backgroundColor = getToolboxBackgroundColor(); + validateTheme(backgroundImage, backgroundColor, color, false); + + await extension.unload(); + + let docEl = window.document.documentElement; + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_experiment.js b/toolkit/components/extensions/test/browser/browser_ext_themes_experiment.js new file mode 100644 index 0000000000..02156b6cd8 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_experiment.js @@ -0,0 +1,450 @@ +"use strict"; + +const { AddonSettings } = ChromeUtils.importESModule( + "resource://gre/modules/addons/AddonSettings.sys.mjs" +); + +// This test checks whether the theme experiments work +add_task(async function test_experiment_static_theme() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + theme: { + colors: { + some_color_property: "#ff00ff", + }, + images: { + some_image_property: "background.jpg", + }, + properties: { + some_random_property: "no-repeat", + }, + }, + theme_experiment: { + colors: { + some_color_property: "--some-color-property", + }, + images: { + some_image_property: "--some-image-property", + }, + properties: { + some_random_property: "--some-random-property", + }, + }, + }, + }); + + const root = window.document.documentElement; + + is( + root.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + is( + root.style.getPropertyValue("--some-image-property"), + "", + "Image property should be unset" + ); + is( + root.style.getPropertyValue("--some-random-property"), + "", + "Generic Property should be unset." + ); + + await extension.startup(); + + const testExperimentApplied = rootEl => { + if (AddonSettings.EXPERIMENTS_ENABLED) { + is( + rootEl.style.getPropertyValue("--some-color-property"), + hexToCSS("#ff00ff"), + "Color property should be parsed and set." + ); + ok( + rootEl.style + .getPropertyValue("--some-image-property") + .startsWith("url("), + "Image property should be parsed." + ); + ok( + rootEl.style + .getPropertyValue("--some-image-property") + .endsWith("background.jpg)"), + "Image property should be set." + ); + is( + rootEl.style.getPropertyValue("--some-random-property"), + "no-repeat", + "Generic Property should be set." + ); + } else { + is( + rootEl.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-image-property"), + "", + "Image property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-random-property"), + "", + "Generic Property should be unset." + ); + } + }; + + info("Testing that current window updated with the experiment applied"); + testExperimentApplied(root); + + info("Testing that new window initialized with the experiment applied"); + const newWindow = await BrowserTestUtils.openNewBrowserWindow(); + const newWindowRoot = newWindow.document.documentElement; + testExperimentApplied(newWindowRoot); + + await extension.unload(); + + info("Testing that both windows unapplied the experiment"); + for (const rootEl of [root, newWindowRoot]) { + is( + rootEl.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-image-property"), + "", + "Image property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-random-property"), + "", + "Generic Property should be unset." + ); + } + await BrowserTestUtils.closeWindow(newWindow); +}); + +add_task(async function test_experiment_dynamic_theme() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + permissions: ["theme"], + theme_experiment: { + colors: { + some_color_property: "--some-color-property", + }, + images: { + some_image_property: "--some-image-property", + }, + properties: { + some_random_property: "--some-random-property", + }, + }, + }, + background() { + const theme = { + colors: { + some_color_property: "#ff00ff", + }, + images: { + some_image_property: "background.jpg", + }, + properties: { + some_random_property: "no-repeat", + }, + }; + browser.test.onMessage.addListener(msg => { + if (msg === "update-theme") { + browser.theme.update(theme).then(() => { + browser.test.sendMessage("theme-updated"); + }); + } else { + browser.theme.reset().then(() => { + browser.test.sendMessage("theme-reset"); + }); + } + }); + }, + }); + + await extension.startup(); + + const root = window.document.documentElement; + + is( + root.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + is( + root.style.getPropertyValue("--some-image-property"), + "", + "Image property should be unset" + ); + is( + root.style.getPropertyValue("--some-random-property"), + "", + "Generic Property should be unset." + ); + + extension.sendMessage("update-theme"); + await extension.awaitMessage("theme-updated"); + + const testExperimentApplied = rootEl => { + if (AddonSettings.EXPERIMENTS_ENABLED) { + is( + rootEl.style.getPropertyValue("--some-color-property"), + hexToCSS("#ff00ff"), + "Color property should be parsed and set." + ); + ok( + rootEl.style + .getPropertyValue("--some-image-property") + .startsWith("url("), + "Image property should be parsed." + ); + ok( + rootEl.style + .getPropertyValue("--some-image-property") + .endsWith("background.jpg)"), + "Image property should be set." + ); + is( + rootEl.style.getPropertyValue("--some-random-property"), + "no-repeat", + "Generic Property should be set." + ); + } else { + is( + rootEl.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-image-property"), + "", + "Image property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-random-property"), + "", + "Generic Property should be unset." + ); + } + }; + testExperimentApplied(root); + + const newWindow = await BrowserTestUtils.openNewBrowserWindow(); + const newWindowRoot = newWindow.document.documentElement; + + testExperimentApplied(newWindowRoot); + + extension.sendMessage("reset-theme"); + await extension.awaitMessage("theme-reset"); + + for (const rootEl of [root, newWindowRoot]) { + is( + rootEl.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-image-property"), + "", + "Image property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-random-property"), + "", + "Generic Property should be unset." + ); + } + + extension.sendMessage("update-theme"); + await extension.awaitMessage("theme-updated"); + + testExperimentApplied(root); + testExperimentApplied(newWindowRoot); + + await extension.unload(); + + for (const rootEl of [root, newWindowRoot]) { + is( + rootEl.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-image-property"), + "", + "Image property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-random-property"), + "", + "Generic Property should be unset." + ); + } + + await BrowserTestUtils.closeWindow(newWindow); +}); + +add_task(async function test_experiment_stylesheet() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + theme: { + colors: { + menu_button_background: "#ff00ff", + }, + }, + theme_experiment: { + stylesheet: "experiment.css", + colors: { + menu_button_background: "--menu-button-background", + }, + }, + }, + files: { + "experiment.css": `#PanelUI-menu-button { + background-color: var(--menu-button-background); + fill: white; + }`, + }, + }); + + const root = window.document.documentElement; + const menuButton = document.getElementById("PanelUI-menu-button"); + const computedStyle = window.getComputedStyle(menuButton); + const expectedColor = hexToCSS("#ff00ff"); + const expectedFill = hexToCSS("#ffffff"); + + is( + root.style.getPropertyValue("--menu-button-background"), + "", + "Variable should be unset" + ); + isnot( + computedStyle.backgroundColor, + expectedColor, + "Menu button should not have custom background" + ); + isnot( + computedStyle.fill, + expectedFill, + "Menu button should not have stylesheet fill" + ); + + await extension.startup(); + + if (AddonSettings.EXPERIMENTS_ENABLED) { + // Wait for stylesheet load. + await BrowserTestUtils.waitForCondition( + () => computedStyle.fill === expectedFill + ); + + is( + root.style.getPropertyValue("--menu-button-background"), + expectedColor, + "Variable should be parsed and set." + ); + is( + computedStyle.backgroundColor, + expectedColor, + "Menu button should be have correct background" + ); + is( + computedStyle.fill, + expectedFill, + "Menu button should be have correct fill" + ); + } else { + is( + root.style.getPropertyValue("--menu-button-background"), + "", + "Variable should be unset" + ); + isnot( + computedStyle.backgroundColor, + expectedColor, + "Menu button should not have custom background" + ); + isnot( + computedStyle.fill, + expectedFill, + "Menu button should not have stylesheet fill" + ); + } + + await extension.unload(); + + is( + root.style.getPropertyValue("--menu-button-background"), + "", + "Variable should be unset" + ); + isnot( + computedStyle.backgroundColor, + expectedColor, + "Menu button should not have custom background" + ); + isnot( + computedStyle.fill, + expectedFill, + "Menu button should not have stylesheet fill" + ); +}); + +// This test checks whether the theme experiments are allowed for non privileged +// theme installed non-temporarily if AddonSettings.EXPERIMENTS_ENABLED is true. +add_task(async function test_experiment_installed_non_temporarily() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.experiments.enabled", true]], + }); + + if (!AddonSettings.EXPERIMENTS_ENABLED) { + info( + "Skipping test case on build where AddonSettings.EXPERIMENTS_ENABLED is false" + ); + return; + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + some_color_property: "#ff00ff", + }, + }, + theme_experiment: { + colors: { + some_color_property: "--some-color-property", + }, + }, + }, + }); + + const root = window.document.documentElement; + + is( + root.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + + await extension.startup(); + + is( + root.style.getPropertyValue("--some-color-property"), + hexToCSS("#ff00ff"), + "Color property should be parsed and set." + ); + + await extension.unload(); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_findbar.js b/toolkit/components/extensions/test/browser/browser_ext_themes_findbar.js new file mode 100644 index 0000000000..a24c90615b --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_findbar.js @@ -0,0 +1,227 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the toolbar and toolbar_field properties also theme the findbar. + +function assertHasNoBorders(element) { + let cs = window.getComputedStyle(element); + Assert.equal(cs.borderTopWidth, "0px", "should have no top border"); + Assert.equal(cs.borderRightWidth, "0px", "should have no right border"); + Assert.equal(cs.borderBottomWidth, "0px", "should have no bottom border"); + Assert.equal(cs.borderLeftWidth, "0px", "should have no left border"); +} + +add_task(async function test_support_toolbar_properties_on_findbar() { + const TOOLBAR_COLOR = "#ff00ff"; + const TOOLBAR_TEXT_COLOR = "#9400ff"; + const ACCENT_COLOR_INACTIVE = "#ffff00"; + // The TabContextMenu initializes its strings only on a focus or mouseover event. + // Calls focus event on the TabContextMenu early in the test. + gBrowser.selectedTab.focus(); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + frame_inactive: ACCENT_COLOR_INACTIVE, + tab_background_text: TEXT_COLOR, + toolbar: TOOLBAR_COLOR, + bookmark_text: TOOLBAR_TEXT_COLOR, + }, + }, + }, + }); + + await extension.startup(); + await gBrowser.getFindBar(); + + let findbar_button = gFindBar.getElement("highlight"); + + info("Checking findbar background is set as toolbar color"); + Assert.equal( + window.getComputedStyle(gFindBar).backgroundColor, + hexToCSS(ACCENT_COLOR), + "Findbar background color should be the same as toolbar background color." + ); + + info("Checking findbar and checkbox text color use toolbar text color"); + const rgbColor = hexToCSS(TOOLBAR_TEXT_COLOR); + Assert.equal( + window.getComputedStyle(gFindBar).color, + rgbColor, + "Findbar text color should be the same as toolbar text color." + ); + Assert.equal( + window.getComputedStyle(findbar_button).color, + rgbColor, + "Findbar checkbox text color should be toolbar text color." + ); + + // Open a new window to check frame_inactive + let window2 = await BrowserTestUtils.openNewBrowserWindow(); + Assert.equal( + window.getComputedStyle(gFindBar).backgroundColor, + hexToCSS(ACCENT_COLOR_INACTIVE), + "Findbar background changed in inactive window." + ); + await BrowserTestUtils.closeWindow(window2); + + await extension.unload(); +}); + +add_task(async function test_support_toolbar_field_properties_on_findbar() { + let findbar_prev_button = gFindBar.getElement("find-previous"); + let findbar_next_button = gFindBar.getElement("find-next"); + + assertHasNoBorders(findbar_prev_button); + assertHasNoBorders(findbar_next_button); + + const TOOLBAR_FIELD_COLOR = "#ff00ff"; + const TOOLBAR_FIELD_TEXT_COLOR = "#9400ff"; + const TOOLBAR_FIELD_BORDER_COLOR = "#ffffff"; + // The TabContextMenu initializes its strings only on a focus or mouseover event. + // Calls focus event on the TabContextMenu early in the test. + gBrowser.selectedTab.focus(); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field: TOOLBAR_FIELD_COLOR, + toolbar_field_text: TOOLBAR_FIELD_TEXT_COLOR, + toolbar_field_border: TOOLBAR_FIELD_BORDER_COLOR, + }, + }, + }, + }); + + await extension.startup(); + await gBrowser.getFindBar(); + + let findbar_textbox = gFindBar.getElement("findbar-textbox"); + + info( + "Checking findbar textbox background is set as toolbar field background color" + ); + Assert.equal( + window.getComputedStyle(findbar_textbox).backgroundColor, + hexToCSS(TOOLBAR_FIELD_COLOR), + "Findbar textbox background color should be the same as toolbar field color." + ); + + info("Checking findbar textbox color is set as toolbar field text color"); + Assert.equal( + window.getComputedStyle(findbar_textbox).color, + hexToCSS(TOOLBAR_FIELD_TEXT_COLOR), + "Findbar textbox text color should be the same as toolbar field text color." + ); + testBorderColor(findbar_textbox, TOOLBAR_FIELD_BORDER_COLOR); + + assertHasNoBorders(findbar_prev_button); + assertHasNoBorders(findbar_next_button); + + await extension.unload(); +}); + +// Test that theme properties are applied with a theme_frame +add_task(async function test_toolbar_properties_on_findbar_with_theme_frame() { + const TOOLBAR_COLOR = "#ff00ff"; + const TOOLBAR_TEXT_COLOR = "#9400ff"; + // The TabContextMenu initializes its strings only on a focus or mouseover event. + // Calls focus event on the TabContextMenu early in the test. + gBrowser.selectedTab.focus(); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar: TOOLBAR_COLOR, + bookmark_text: TOOLBAR_TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + await gBrowser.getFindBar(); + + let findbar_button = gFindBar.getElement("highlight"); + + info("Checking findbar background is set as toolbar color"); + Assert.equal( + window.getComputedStyle(gFindBar).backgroundColor, + hexToCSS(ACCENT_COLOR), + "Findbar background color should be set by theme." + ); + + info("Checking findbar and button text color is set as toolbar text color"); + Assert.equal( + window.getComputedStyle(gFindBar).color, + hexToCSS(TOOLBAR_TEXT_COLOR), + "Findbar text color should be set by theme." + ); + Assert.equal( + window.getComputedStyle(findbar_button).color, + hexToCSS(TOOLBAR_TEXT_COLOR), + "Findbar button text color should be set by theme." + ); + + await extension.unload(); +}); + +add_task( + async function test_toolbar_field_properties_on_findbar_with_theme_frame() { + const TOOLBAR_FIELD_COLOR = "#ff00ff"; + const TOOLBAR_FIELD_TEXT_COLOR = "#9400ff"; + const TOOLBAR_FIELD_BORDER_COLOR = "#ffffff"; + // The TabContextMenu initializes its strings only on a focus or mouseover event. + // Calls focus event on the TabContextMenu early in the test. + gBrowser.selectedTab.focus(); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field: TOOLBAR_FIELD_COLOR, + toolbar_field_text: TOOLBAR_FIELD_TEXT_COLOR, + toolbar_field_border: TOOLBAR_FIELD_BORDER_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + await gBrowser.getFindBar(); + + let findbar_textbox = gFindBar.getElement("findbar-textbox"); + + Assert.equal( + window.getComputedStyle(findbar_textbox).backgroundColor, + hexToCSS(TOOLBAR_FIELD_COLOR), + "Findbar textbox background color should be set by theme." + ); + + Assert.equal( + window.getComputedStyle(findbar_textbox).color, + hexToCSS(TOOLBAR_FIELD_TEXT_COLOR), + "Findbar textbox text color should be set by theme." + ); + + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_getCurrent_differentExt.js b/toolkit/components/extensions/test/browser/browser_ext_themes_getCurrent_differentExt.js new file mode 100644 index 0000000000..587c5d4efe --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_getCurrent_differentExt.js @@ -0,0 +1,151 @@ +"use strict"; + +// This test checks whether browser.theme.getCurrent() works correctly when theme +// does not originate from extension querying the theme. +const THEME = { + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, +}; + +add_task(async function test_getcurrent() { + const theme = ExtensionTestUtils.loadExtension(THEME); + const extension = ExtensionTestUtils.loadExtension({ + background() { + browser.theme.onUpdated.addListener(() => { + browser.theme.getCurrent().then(theme => { + browser.test.sendMessage("theme-updated", theme); + if (!theme?.images) { + return; + } + + // Try to access the theme_frame image + fetch(theme.images.theme_frame) + .then(() => { + browser.test.sendMessage("theme-image", { success: true }); + }) + .catch(e => { + browser.test.sendMessage("theme-image", { + success: false, + error: e.message, + }); + }); + }); + }); + }, + }); + + await extension.startup(); + + info("Testing getCurrent after static theme startup"); + let updatedPromise = extension.awaitMessage("theme-updated"); + let imageLoaded = extension.awaitMessage("theme-image"); + await theme.startup(); + let receivedTheme = await updatedPromise; + Assert.ok( + receivedTheme.images.theme_frame.includes("image1.png"), + "getCurrent returns correct theme_frame image" + ); + Assert.equal( + receivedTheme.colors.frame, + ACCENT_COLOR, + "getCurrent returns correct frame color" + ); + Assert.equal( + receivedTheme.colors.tab_background_text, + TEXT_COLOR, + "getCurrent returns correct tab_background_text color" + ); + Assert.deepEqual(await imageLoaded, { success: true }, "theme image loaded"); + + info("Testing getCurrent after static theme unload"); + updatedPromise = extension.awaitMessage("theme-updated"); + await theme.unload(); + receivedTheme = await updatedPromise; + Assert.equal( + JSON.stringify({ colors: null, images: null, properties: null }), + JSON.stringify(receivedTheme), + "getCurrent returns empty theme" + ); + + await extension.unload(); +}); + +add_task(async function test_getcurrent_privateBrowsing() { + const theme = ExtensionTestUtils.loadExtension(THEME); + + const extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + // We don't want the sidebar to automatically open on extension startup. + startupReason: "APP_STARTUP", + files: { + "sidebar.html": ` + + + Test Extension Sidebar + + + + `, + "sidebar.js": function () { + browser.theme.getCurrent().then(theme => { + if (!theme?.images) { + browser.test.fail( + `Missing expected images from theme.getCurrent result` + ); + return; + } + + // Try to access the theme_frame image + fetch(theme.images.theme_frame) + .then(() => { + browser.test.sendMessage("theme-image", { success: true }); + }) + .catch(e => { + browser.test.sendMessage("theme-image", { + success: false, + error: e.message, + }); + }); + }); + }, + }, + }); + + await extension.startup(); + await theme.startup(); + + const privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + const { ExtensionCommon } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionCommon.sys.mjs" + ); + const { makeWidgetId } = ExtensionCommon; + privateWin.SidebarUI.show(`${makeWidgetId(extension.id)}-sidebar-action`); + + let imageLoaded = extension.awaitMessage("theme-image"); + Assert.deepEqual(await imageLoaded, { success: true }, "theme image loaded"); + + await extension.unload(); + await theme.unload(); + + await BrowserTestUtils.closeWindow(privateWin); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_highlight.js b/toolkit/components/extensions/test/browser/browser_ext_themes_highlight.js new file mode 100644 index 0000000000..5a0d1c7a8d --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_highlight.js @@ -0,0 +1,63 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the color of the font and background in a selection are applied properly. +const { CustomizableUITestUtils } = ChromeUtils.importESModule( + "resource://testing-common/CustomizableUITestUtils.sys.mjs" +); +let gCUITestUtils = new CustomizableUITestUtils(window); +add_setup(async function () { + await gCUITestUtils.addSearchBar(); + await gFindBarPromise; + registerCleanupFunction(() => { + gFindBar.close(); + gCUITestUtils.removeSearchBar(); + }); +}); + +add_task(async function test_support_selection() { + const HIGHLIGHT_TEXT_COLOR = "#9400FF"; + const HIGHLIGHT_COLOR = "#F89919"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + toolbar_field_highlight: HIGHLIGHT_COLOR, + toolbar_field_highlight_text: HIGHLIGHT_TEXT_COLOR, + }, + }, + }, + }); + await extension.startup(); + + let fields = [ + gURLBar.inputField, + document.querySelector("#searchbar .searchbar-textbox"), + document.querySelector(".findbar-textbox"), + ].filter(field => { + let bounds = field.getBoundingClientRect(); + return bounds.width > 0 && bounds.height > 0; + }); + + Assert.equal(fields.length, 3, "Should be testing three elements"); + + info( + `Checking background colors and colors for ${fields.length} toolbar input fields.` + ); + for (let field of fields) { + info(`Testing ${field.id || field.className}`); + field.focus(); + Assert.equal( + window.getComputedStyle(field, "::selection").backgroundColor, + hexToCSS(HIGHLIGHT_COLOR), + "Input selection background should be set." + ); + Assert.equal( + window.getComputedStyle(field, "::selection").color, + hexToCSS(HIGHLIGHT_TEXT_COLOR), + "Input selection color should be set." + ); + } + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_incognito.js b/toolkit/components/extensions/test/browser/browser_ext_themes_incognito.js new file mode 100644 index 0000000000..d9beb0f9a8 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_incognito.js @@ -0,0 +1,77 @@ +"use strict"; + +add_task(async function test_theme_incognito_not_allowed() { + let windowExtension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + async background() { + const theme = { + colors: { + frame: "black", + tab_background_text: "black", + }, + }; + let window = await browser.windows.create({ incognito: true }); + browser.test.onMessage.addListener(async message => { + if (message == "update") { + browser.theme.update(window.id, theme); + return; + } + await browser.windows.remove(window.id); + browser.test.sendMessage("done"); + }); + browser.test.sendMessage("ready", window.id); + }, + manifest: { + permissions: ["theme"], + }, + }); + await windowExtension.startup(); + let wId = await windowExtension.awaitMessage("ready"); + + async function background(windowId) { + const theme = { + colors: { + frame: "black", + tab_background_text: "black", + }, + }; + + browser.theme.onUpdated.addListener(info => { + browser.test.log("got theme onChanged"); + browser.test.fail("theme"); + }); + await browser.test.assertRejects( + browser.theme.getCurrent(windowId), + /Invalid window ID/, + "API should reject getting window theme" + ); + await browser.test.assertRejects( + browser.theme.update(windowId, theme), + /Invalid window ID/, + "API should reject updating theme" + ); + await browser.test.assertRejects( + browser.theme.reset(windowId), + /Invalid window ID/, + "API should reject reseting theme on window" + ); + + browser.test.sendMessage("start"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})(${wId})`, + manifest: { + permissions: ["theme"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("start"); + windowExtension.sendMessage("update"); + + windowExtension.sendMessage("close"); + await windowExtension.awaitMessage("done"); + await windowExtension.unload(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_lwtsupport.js b/toolkit/components/extensions/test/browser/browser_ext_themes_lwtsupport.js new file mode 100644 index 0000000000..0458558ee4 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_lwtsupport.js @@ -0,0 +1,56 @@ +"use strict"; + +const DEFAULT_THEME_BG_COLOR = "rgb(255, 255, 255)"; +const DEFAULT_THEME_TEXT_COLOR = "rgb(0, 0, 0)"; + +add_task(async function test_deprecated_LWT_properties_ignored() { + // This test uses deprecated theme properties, so warnings are expected. + ExtensionTestUtils.failOnSchemaWarnings(false); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + headerURL: "image1.png", + }, + colors: { + accentcolor: ACCENT_COLOR, + textcolor: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + let docStyle = window.getComputedStyle(docEl); + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.ok( + !docEl.hasAttribute("lwtheme-image"), + "LWT image attribute should not be set on deprecated headerURL alias" + ); + Assert.ok( + !docEl.getAttribute("lwtheme-brighttext"), + "LWT text color attribute should not be set on deprecated textcolor alias" + ); + + Assert.equal( + getToolboxBackgroundColor(), + DEFAULT_THEME_BG_COLOR, + "Expected default theme background color" + ); + + Assert.equal( + docStyle.color, + DEFAULT_THEME_TEXT_COLOR, + "Expected default theme text color" + ); + + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_multiple_backgrounds.js b/toolkit/components/extensions/test/browser/browser_ext_themes_multiple_backgrounds.js new file mode 100644 index 0000000000..2115d8d23c --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_multiple_backgrounds.js @@ -0,0 +1,202 @@ +"use strict"; + +add_task(async function test_support_backgrounds_position() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "face1.png", + additional_backgrounds: ["face2.png", "face2.png", "face2.png"], + }, + colors: { + frame: `rgb(${FRAME_COLOR.join(",")})`, + tab_background_text: `rgb(${TAB_BACKGROUND_TEXT_COLOR.join(",")})`, + }, + properties: { + additional_backgrounds_alignment: [ + "left top", + "center top", + "right bottom", + ], + }, + }, + }, + files: { + "face1.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + "face2.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + let toolbox = document.querySelector("#navigator-toolbox"); + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal( + docEl.getAttribute("lwtheme-brighttext"), + "true", + "LWT text color attribute should be set" + ); + + let toolboxCS = window.getComputedStyle(toolbox); + let toolboxBgImage = toolboxCS.backgroundImage.split(",")[0].trim(); + Assert.equal( + toolboxCS.backgroundImage, + [1, 2, 2, 2] + .map(num => toolboxBgImage.replace(/face[\d]*/, `face${num}`)) + .join(", "), + "The backgroundImage should use face1.png once and face2.png three times." + ); + Assert.equal( + toolboxCS.backgroundPosition, + "100% 0%, 0% 0%, 50% 0%, 100% 100%", + "The backgroundPosition should use the three values provided, preceded by the default for theme_frame." + ); + /** + * We expect duplicate background-repeat values because we apply `no-repeat` + * once for theme_frame, and again as the default value of + * --lwt-background-tiling. + */ + Assert.equal( + toolboxCS.backgroundRepeat, + "no-repeat, no-repeat", + "The backgroundPosition should use the default value." + ); + + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); + toolboxCS = window.getComputedStyle(toolbox); + + // Styles should've reverted to their initial values. + Assert.equal(toolboxCS.backgroundImage, "none"); + Assert.equal(toolboxCS.backgroundPosition, "0% 0%"); + Assert.equal(toolboxCS.backgroundRepeat, "repeat"); +}); + +add_task(async function test_support_backgrounds_repeat() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "face0.png", + additional_backgrounds: ["face1.png", "face2.png", "face3.png"], + }, + colors: { + frame: FRAME_COLOR, + tab_background_text: TAB_BACKGROUND_TEXT_COLOR, + }, + properties: { + additional_backgrounds_tiling: ["repeat-x", "repeat-y", "repeat"], + }, + }, + }, + files: { + "face0.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + "face1.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + "face2.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + "face3.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + let toolbox = document.querySelector("#navigator-toolbox"); + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal( + docEl.getAttribute("lwtheme-brighttext"), + "true", + "LWT text color attribute should be set" + ); + + let toolboxCS = window.getComputedStyle(toolbox); + let toolboxImage = toolboxCS.backgroundImage.split(",")[0].trim(); + Assert.equal( + [0, 1, 2, 3] + .map(num => toolboxImage.replace(/face[\d]*/, `face${num}`)) + .join(", "), + toolboxCS.backgroundImage, + "The backgroundImage should use face.png four times." + ); + /** + * We expect duplicate background-position values because we apply `right top` + * once for theme_frame, and again as the default value of + * --lwt-background-alignment. + */ + Assert.equal( + toolboxCS.backgroundPosition, + "100% 0%, 100% 0%", + "The backgroundPosition should use the default value for navigator-toolbox." + ); + Assert.equal( + toolboxCS.backgroundRepeat, + "no-repeat, repeat-x, repeat-y, repeat", + "The backgroundRepeat should use the three values provided for --lwt-background-tiling, preceeded by the default for theme_frame." + ); + + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); + +add_task(async function test_additional_images_check() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "face.png", + }, + colors: { + frame: FRAME_COLOR, + tab_background_text: TAB_BACKGROUND_TEXT_COLOR, + }, + properties: { + additional_backgrounds_tiling: ["repeat-x", "repeat-y", "repeat"], + }, + }, + }, + files: { + "face.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + let toolbox = document.querySelector("#navigator-toolbox"); + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal( + docEl.getAttribute("lwtheme-brighttext"), + "true", + "LWT text color attribute should be set" + ); + + let toolboxCS = window.getComputedStyle(toolbox); + let bgImage = toolboxCS.backgroundImage.split(",")[0].trim(); + Assert.ok( + bgImage.includes("face.png"), + `The backgroundImage should use face.png. Actual value is: ${bgImage}` + ); + Assert.ok( + bgImage.includes("face.png"), + `The backgroundImage should use face.png. Actual value is: ${bgImage}` + ); + Assert.equal( + toolboxCS.backgroundPosition, + "100% 0%, 100% 0%", + "The backgroundPosition should use the default value." + ); + Assert.equal( + toolboxCS.backgroundRepeat, + "no-repeat, no-repeat", + "The backgroundRepeat should use the default value." + ); + + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors.js b/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors.js new file mode 100644 index 0000000000..8e2f5446c9 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors.js @@ -0,0 +1,203 @@ +"use strict"; +// This test checks whether the new tab page color properties work. + +function waitForAboutNewTabReady(browser, url) { + // Stop-gap fix for https://bugzilla.mozilla.org/show_bug.cgi?id=1697196#c24 + return SpecialPowers.spawn(browser, [url], async url => { + let doc = content.document; + await ContentTaskUtils.waitForCondition( + () => doc.querySelector(".outer-wrapper"), + `Waiting for page wrapper to be initialized at ${url}` + ); + }); +} + +/** + * Test whether the selected browser has the new tab page theme applied + * + * @param {object} theme that is applied + * @param {boolean} isBrightText whether the brighttext attribute should be set + */ +async function test_ntp_theme(theme, isBrightText) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme, + }, + }); + + let browser = gBrowser.selectedBrowser; + + let { originalBackground, originalCardBackground, originalColor } = + await SpecialPowers.spawn(browser, [], function () { + let doc = content.document; + ok( + !doc.documentElement.hasAttribute("lwt-newtab"), + "New tab page should not have lwt-newtab attribute" + ); + ok( + !doc.documentElement.hasAttribute("lwt-newtab-brighttext"), + `New tab page should not have lwt-newtab-brighttext attribute` + ); + + return { + originalBackground: content.getComputedStyle(doc.body).backgroundColor, + originalCardBackground: content.getComputedStyle( + doc.querySelector(".top-site-outer .tile") + ).backgroundColor, + originalColor: content.getComputedStyle( + doc.querySelector(".outer-wrapper") + ).color, + // We check the value of --newtab-link-primary-color directly because the + // elements on which it is applied are hard to test. It is most visible in + // the "learn more" link in the Pocket section. We cannot show the Pocket + // section since it hits the network, and the usual workarounds to change + // its backend only work in browser/. This variable is also used in + // the Edit Top Site modal, but showing/hiding that is very verbose and + // would make this test almost unreadable. + originalLinks: content + .getComputedStyle(doc.documentElement) + .getPropertyValue("--newtab-link-primary-color"), + }; + }); + + await extension.startup(); + + Services.ppmm.sharedData.flush(); + + await SpecialPowers.spawn( + browser, + [ + { + isBrightText, + background: hexToCSS(theme.colors.ntp_background), + card_background: hexToCSS(theme.colors.ntp_card_background), + color: hexToCSS(theme.colors.ntp_text), + }, + ], + async function ({ isBrightText, background, card_background, color }) { + let doc = content.document; + ok( + doc.documentElement.hasAttribute("lwt-newtab"), + "New tab page should have lwt-newtab attribute" + ); + is( + doc.documentElement.hasAttribute("lwt-newtab-brighttext"), + isBrightText, + `New tab page should${ + !isBrightText ? " not" : "" + } have lwt-newtab-brighttext attribute` + ); + + is( + content.getComputedStyle(doc.body).backgroundColor, + background, + "New tab page background should be set." + ); + is( + content.getComputedStyle(doc.querySelector(".top-site-outer .tile")) + .backgroundColor, + card_background, + "New tab page card background should be set." + ); + is( + content.getComputedStyle(doc.querySelector(".outer-wrapper")).color, + color, + "New tab page text color should be set." + ); + } + ); + + await extension.unload(); + + Services.ppmm.sharedData.flush(); + + await SpecialPowers.spawn( + browser, + [ + { + originalBackground, + originalCardBackground, + originalColor, + }, + ], + function ({ originalBackground, originalCardBackground, originalColor }) { + let doc = content.document; + ok( + !doc.documentElement.hasAttribute("lwt-newtab"), + "New tab page should not have lwt-newtab attribute" + ); + ok( + !doc.documentElement.hasAttribute("lwt-newtab-brighttext"), + `New tab page should not have lwt-newtab-brighttext attribute` + ); + + is( + content.getComputedStyle(doc.body).backgroundColor, + originalBackground, + "New tab page background should be reset." + ); + is( + content.getComputedStyle(doc.querySelector(".top-site-outer .tile")) + .backgroundColor, + originalCardBackground, + "New tab page card background should be reset." + ); + is( + content.getComputedStyle(doc.querySelector(".outer-wrapper")).color, + originalColor, + "New tab page text color should be reset." + ); + } + ); +} + +add_task(async function test_support_ntp_colors() { + await SpecialPowers.pushPrefEnv({ + set: [ + // BrowserTestUtils.withNewTab waits for about:newtab to load + // so we disable preloading before running the test. + ["browser.newtab.preload", false], + // Force prefers-color-scheme to "light", as otherwise it might be + // derived from the theme, but we hard-code the light styles on this + // test. + ["layout.css.prefers-color-scheme.content-override", 1], + // Override the system color scheme to light so this test passes on + // machines with dark system color scheme. + ["ui.systemUsesDarkTheme", 0], + ], + }); + NewTabPagePreloading.removePreloadedBrowser(window); + for (let url of ["about:newtab", "about:home"]) { + info("Opening url: " + url); + await BrowserTestUtils.withNewTab({ gBrowser, url }, async browser => { + await waitForAboutNewTabReady(browser, url); + await test_ntp_theme( + { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + ntp_background: "#add8e6", + ntp_card_background: "#ffffff", + ntp_text: "#00008b", + }, + }, + false, + url + ); + + await test_ntp_theme( + { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + ntp_background: "#00008b", + ntp_card_background: "#000000", + ntp_text: "#add8e6", + }, + }, + true, + url + ); + }); + } +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors_perwindow.js b/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors_perwindow.js new file mode 100644 index 0000000000..9d28cf50c8 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors_perwindow.js @@ -0,0 +1,240 @@ +"use strict"; + +// This test checks whether the new tab page color properties work per-window. + +function waitForAboutNewTabReady(browser, url) { + // Stop-gap fix for https://bugzilla.mozilla.org/show_bug.cgi?id=1697196#c24 + return SpecialPowers.spawn(browser, [url], async url => { + let doc = content.document; + await ContentTaskUtils.waitForCondition( + () => doc.querySelector(".outer-wrapper"), + `Waiting for page wrapper to be initialized at ${url}` + ); + }); +} + +/** + * Test whether a given browser has the new tab page theme applied + * + * @param {object} browser to test against + * @param {object} theme that is applied + * @param {boolean} isBrightText whether the brighttext attribute should be set + * @returns {Promise} The task as a promise + */ +function test_ntp_theme(browser, theme, isBrightText) { + Services.ppmm.sharedData.flush(); + return SpecialPowers.spawn( + browser, + [ + { + isBrightText, + background: hexToCSS(theme.colors.ntp_background), + card_background: hexToCSS(theme.colors.ntp_card_background), + color: hexToCSS(theme.colors.ntp_text), + }, + ], + function ({ isBrightText, background, card_background, color }) { + let doc = content.document; + ok( + doc.documentElement.hasAttribute("lwt-newtab"), + "New tab page should have lwt-newtab attribute" + ); + is( + doc.documentElement.hasAttribute("lwt-newtab-brighttext"), + isBrightText, + `New tab page should${ + !isBrightText ? " not" : "" + } have lwt-newtab-brighttext attribute` + ); + + is( + content.getComputedStyle(doc.body).backgroundColor, + background, + "New tab page background should be set." + ); + is( + content.getComputedStyle(doc.querySelector(".top-site-outer .tile")) + .backgroundColor, + card_background, + "New tab page card background should be set." + ); + is( + content.getComputedStyle(doc.querySelector(".outer-wrapper")).color, + color, + "New tab page text color should be set." + ); + } + ); +} + +/** + * Test whether a given browser has the default theme applied + * + * @param {object} browser to test against + * @param {string} url being tested + * @returns {Promise} The task as a promise + */ +function test_ntp_default_theme(browser, url) { + Services.ppmm.sharedData.flush(); + return SpecialPowers.spawn( + browser, + [ + { + background: hexToCSS("#F9F9FB"), + color: hexToCSS("#15141A"), + }, + ], + function ({ background, color }) { + let doc = content.document; + ok( + !doc.documentElement.hasAttribute("lwt-newtab"), + "New tab page should not have lwt-newtab attribute" + ); + ok( + !doc.documentElement.hasAttribute("lwt-newtab-brighttext"), + `New tab page should not have lwt-newtab-brighttext attribute` + ); + + is( + content.getComputedStyle(doc.body).backgroundColor, + background, + "New tab page background should be reset." + ); + is( + content.getComputedStyle(doc.querySelector(".outer-wrapper")).color, + color, + "New tab page text color should be reset." + ); + } + ); +} + +add_task(async function test_per_window_ntp_theme() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + async background() { + function promiseWindowChecked() { + return new Promise(resolve => { + let listener = msg => { + if (msg == "checked-window") { + browser.test.onMessage.removeListener(listener); + resolve(); + } + }; + browser.test.onMessage.addListener(listener); + }); + } + + function removeWindow(winId) { + return new Promise(resolve => { + let listener = removedWinId => { + if (removedWinId == winId) { + browser.windows.onRemoved.removeListener(listener); + resolve(); + } + }; + browser.windows.onRemoved.addListener(listener); + browser.windows.remove(winId); + }); + } + + async function checkWindow(theme, isBrightText, winId) { + let windowChecked = promiseWindowChecked(); + browser.test.sendMessage("check-window", { + theme, + isBrightText, + winId, + }); + await windowChecked; + } + + const darkTextTheme = { + colors: { + frame: "#add8e6", + tab_background_text: "#000", + ntp_background: "#add8e6", + ntp_card_background: "#ff0000", + ntp_text: "#000", + }, + }; + + const brightTextTheme = { + colors: { + frame: "#00008b", + tab_background_text: "#add8e6", + ntp_background: "#00008b", + ntp_card_background: "#00ff00", + ntp_text: "#add8e6", + }, + }; + + let { id: winId } = await browser.windows.getCurrent(); + // We are opening about:blank instead of the default homepage, + // because using the default homepage results in intermittent + // test failures on debug builds due to browser window leaks. + // A side effect of testing on about:blank is that + // test_ntp_default_theme cannot test properties used only on + // about:newtab, like ntp_card_background. + let { id: secondWinId } = await browser.windows.create({ + url: "about:blank", + }); + + browser.test.log("Test that single window update works"); + await browser.theme.update(winId, darkTextTheme); + await checkWindow(darkTextTheme, false, winId); + await checkWindow(null, false, secondWinId); + + browser.test.log("Test that applying different themes on both windows"); + await browser.theme.update(secondWinId, brightTextTheme); + await checkWindow(darkTextTheme, false, winId); + await checkWindow(brightTextTheme, true, secondWinId); + + browser.test.log("Test resetting the theme on one window"); + await browser.theme.reset(winId); + await checkWindow(null, false, winId); + await checkWindow(brightTextTheme, true, secondWinId); + + await removeWindow(secondWinId); + await checkWindow(null, false, winId); + browser.test.notifyPass("perwindow-ntp-theme"); + }, + }); + + extension.onMessage( + "check-window", + async ({ theme, isBrightText, winId }) => { + let win = Services.wm.getOuterWindowWithId(winId); + win.NewTabPagePreloading.removePreloadedBrowser(win); + // These pages were initially chosen because LightweightThemeChild.sys.mjs + // treats them specially. + for (let url of ["about:newtab", "about:home"]) { + info("Opening url: " + url); + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url }, + async browser => { + await waitForAboutNewTabReady(browser, url); + if (theme) { + await test_ntp_theme(browser, theme, isBrightText); + } else { + await test_ntp_default_theme(browser, url); + } + } + ); + } + extension.sendMessage("checked-window"); + } + ); + + // BrowserTestUtils.withNewTab waits for about:newtab to load + // so we disable preloading before running the test. + await SpecialPowers.setBoolPref("browser.newtab.preload", false); + registerCleanupFunction(() => { + SpecialPowers.clearUserPref("browser.newtab.preload"); + }); + + await extension.startup(); + await extension.awaitFinish("perwindow-ntp-theme"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_pbm.js b/toolkit/components/extensions/test/browser/browser_ext_themes_pbm.js new file mode 100644 index 0000000000..3b36a256d0 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_pbm.js @@ -0,0 +1,422 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/** + * Tests that we apply dark theme variants to PBM windows where applicable. + */ + +const { BuiltInThemes } = ChromeUtils.importESModule( + "resource:///modules/BuiltInThemes.sys.mjs" +); +const { PromptTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromptTestUtils.sys.mjs" +); + +const LIGHT_THEME_ID = "firefox-compact-light@mozilla.org"; +const DARK_THEME_ID = "firefox-compact-dark@mozilla.org"; + +// This tests opens many chrome windows which is slow on debug builds. +requestLongerTimeout(2); + +async function testIsDark(win, expectDark) { + let mql = win.matchMedia("(prefers-color-scheme: dark)"); + if (mql.matches != expectDark) { + // The color scheme change might not have been processed yet, since that + // happens on a refresh driver tick. + await new Promise(r => mql.addEventListener("change", r, { once: true })); + } + is( + mql.matches, + expectDark, + `Window should${expectDark ? "" : " not"} be dark.` + ); +} + +/** + * Test a window's theme color scheme. + * + * @param {*} options - Test options. + * @param {Window} options.win - Window object to test. + * @param {boolean} options.colorScheme - Whether expected chrome color scheme + * is dark (true) or light (false). + * @param {boolean} options.expectLWTAttributes - Whether the window should + * have the LWT attributes set matching the color scheme. + */ +async function testWindowColorScheme({ win, expectDark, expectLWTAttributes }) { + let docEl = win.document.documentElement; + + await testIsDark(win, expectDark); + + if (expectLWTAttributes) { + ok(docEl.hasAttribute("lwtheme"), "Window should have LWT attribute."); + is( + docEl.getAttribute("lwtheme-brighttext"), + expectDark ? "true" : null, + "LWT text color attribute should be set." + ); + } else { + ok(!docEl.hasAttribute("lwtheme"), "Window should not have LWT attribute."); + ok( + !docEl.hasAttribute("lwtheme-brighttext"), + "LWT text color attribute should not be set." + ); + } +} + +/** + * Match the prefers-color-scheme media query and return the results. + * + * @param {object} options + * @param {Window} options.win - If chrome=true, window to test, otherwise + * parent window of the content window to test. + * @param {boolean} options.chrome - If true the media queries will be matched + * against the supplied chrome window. Otherwise they will be matched against + * the content window. + * @returns {Promise<{light: boolean, dark: boolean}>} - Resolves with an + * object of the media query results. + */ +function getPrefersColorSchemeInfo({ win, chrome = false }) { + let fn = async windowObj => { + // If called in the parent, we use the supplied win object. Otherwise use + // the content window global. + let win = windowObj || content; + + // LookAndFeel updates are async. + await new Promise(resolve => { + win.requestAnimationFrame(() => win.requestAnimationFrame(resolve)); + }); + return { + light: win.matchMedia("(prefers-color-scheme: light)").matches, + dark: win.matchMedia("(prefers-color-scheme: dark)").matches, + }; + }; + + if (chrome) { + return fn(win); + } + + return SpecialPowers.spawn(win.gBrowser.selectedBrowser, [], fn); +} + +add_setup(async function () { + // Set system theme to light to ensure consistency across test machines. + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.theme.dark-private-windows", true], + ["ui.systemUsesDarkTheme", 0], + ], + }); + // Ensure the built-in themes are initialized. + await BuiltInThemes.ensureBuiltInThemes(); + + // The previous test, browser_ext_themes_ntp_colors.js has side effects. + // Switch to a theme, then switch back to the default theme to reach a + // consistent themeData state. Without this, themeData in + // LightWeightConsumer#_update does not contain darkTheme data and PBM windows + // don't get themed correctly. + let lightTheme = await AddonManager.getAddonByID(LIGHT_THEME_ID); + await lightTheme.enable(); + await lightTheme.disable(); +}); + +// For the default theme with light color scheme, private browsing windows +// should be themed dark. +// The PBM window's content should not be themed dark. +add_task(async function test_default_theme_light() { + info("Normal browsing window should not be in dark mode."); + await testWindowColorScheme({ + win: window, + expectDark: false, + expectLWTAttributes: false, + }); + + let windowB = await BrowserTestUtils.openNewBrowserWindow(); + + info("Additional normal browsing window should not be in dark mode."); + await testWindowColorScheme({ + win: windowB, + expectDark: false, + expectLWTAttributes: false, + }); + + let pbmWindowA = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + info("Private browsing window should be in dark mode."); + await testWindowColorScheme({ + win: pbmWindowA, + expectDark: true, + expectLWTAttributes: true, + }); + + let prefersColorScheme = await getPrefersColorSchemeInfo({ win: pbmWindowA }); + ok( + prefersColorScheme.light && !prefersColorScheme.dark, + "Content of dark themed PBM window should still be themed light" + ); + + let pbmWindowB = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + info("Additional private browsing window should be in dark mode."); + await testWindowColorScheme({ + win: pbmWindowB, + expectDark: true, + expectLWTAttributes: true, + }); + + await BrowserTestUtils.closeWindow(windowB); + await BrowserTestUtils.closeWindow(pbmWindowA); + await BrowserTestUtils.closeWindow(pbmWindowB); +}); + +// For the default theme with dark color scheme, normal and private browsing +// windows should be themed dark. +add_task(async function test_default_theme_dark() { + // Set the system theme to dark. The default theme will follow this color + // scheme. + await SpecialPowers.pushPrefEnv({ set: [["ui.systemUsesDarkTheme", 1]] }); + + info("Normal browsing window should be in dark mode."); + await testWindowColorScheme({ + win: window, + expectDark: true, + expectLWTAttributes: false, + }); + + let pbmWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + info("Private browsing window should be in dark mode."); + await testWindowColorScheme({ + win: pbmWindow, + expectDark: true, + expectLWTAttributes: false, + }); + + await BrowserTestUtils.closeWindow(pbmWindow); + + await SpecialPowers.popPrefEnv(); +}); + +// For the light theme both normal and private browsing windows should have a +// bright color scheme applied. +add_task(async function test_light_theme_builtin() { + let lightTheme = await AddonManager.getAddonByID(LIGHT_THEME_ID); + await lightTheme.enable(); + + info("Normal browsing window should not be in dark mode."); + await testWindowColorScheme({ + win: window, + expectDark: false, + expectLWTAttributes: true, + }); + + let pbmWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + info("Private browsing window should not be in dark mode."); + await testWindowColorScheme({ + win: pbmWindow, + expectDark: false, + expectLWTAttributes: true, + }); + + await BrowserTestUtils.closeWindow(pbmWindow); + await lightTheme.disable(); +}); + +// For the dark theme both normal and private browsing should have a dark color +// scheme applied. +add_task(async function test_dark_theme_builtin() { + let darkTheme = await AddonManager.getAddonByID(DARK_THEME_ID); + await darkTheme.enable(); + + info("Normal browsing window should be in dark mode."); + await testWindowColorScheme({ + win: window, + expectDark: true, + expectLWTAttributes: true, + }); + + let pbmWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + info("Private browsing window should be in dark mode."); + await testWindowColorScheme({ + win: pbmWindow, + expectDark: true, + expectLWTAttributes: true, + }); + + await BrowserTestUtils.closeWindow(pbmWindow); + await darkTheme.disable(); +}); + +// When switching between default, light and dark theme the private browsing +// window color scheme should update accordingly. +add_task(async function test_theme_switch_updates_existing_pbm_win() { + let windowB = await BrowserTestUtils.openNewBrowserWindow(); + let pbmWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + info("Normal browsing window should not be in dark mode."); + await testWindowColorScheme({ + win: window, + expectDark: false, + expectLWTAttributes: false, + }); + + info("Private browsing window should be in dark mode."); + await testWindowColorScheme({ + win: pbmWindow, + expectDark: true, + expectLWTAttributes: true, + }); + + info("Enabling light theme."); + let lightTheme = await AddonManager.getAddonByID(LIGHT_THEME_ID); + await lightTheme.enable(); + + info("Normal browsing window should not be in dark mode."); + await testWindowColorScheme({ + win: window, + expectDark: false, + expectLWTAttributes: true, + }); + + info("Private browsing window should not be in dark mode."); + await testWindowColorScheme({ + win: pbmWindow, + expectDark: false, + expectLWTAttributes: true, + }); + + await lightTheme.disable(); + + info("Enabling dark theme."); + let darkTheme = await AddonManager.getAddonByID(DARK_THEME_ID); + await darkTheme.enable(); + + info("Normal browsing window should be in dark mode."); + await testWindowColorScheme({ + win: window, + expectDark: true, + expectLWTAttributes: true, + }); + + info("Private browsing window should be in dark mode."); + await testWindowColorScheme({ + win: pbmWindow, + expectDark: true, + expectLWTAttributes: true, + }); + + await darkTheme.disable(); + + await BrowserTestUtils.closeWindow(windowB); + await BrowserTestUtils.closeWindow(pbmWindow); +}); + +// pageInfo windows should inherit the PBM window dark theme. +add_task(async function test_pbm_dark_page_info() { + for (let isPBM of [false, true]) { + let win = await BrowserTestUtils.openNewBrowserWindow({ + private: isPBM, + }); + let windowTypeStr = isPBM ? "private" : "normal"; + + info(`Opening pageInfo from ${windowTypeStr} browsing.`); + + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url: "https://example.com" }, + async () => { + let pageInfo = win.BrowserPageInfo(null, "securityTab"); + await BrowserTestUtils.waitForEvent(pageInfo, "page-info-init"); + + let prefersColorScheme = await getPrefersColorSchemeInfo({ + win: pageInfo, + chrome: true, + }); + if (isPBM) { + ok( + !prefersColorScheme.light && prefersColorScheme.dark, + "pageInfo from private window should be themed dark." + ); + } else { + ok( + prefersColorScheme.light && !prefersColorScheme.dark, + "pageInfo from normal window should be themed light." + ); + } + + pageInfo.close(); + } + ); + + await BrowserTestUtils.closeWindow(win); + } +}); + +// Prompts should inherit the PBM window dark theme. +add_task(async function test_pbm_dark_prompts() { + const { MODAL_TYPE_TAB, MODAL_TYPE_CONTENT } = Services.prompt; + + for (let isPBM of [false, true]) { + let win = await BrowserTestUtils.openNewBrowserWindow({ + private: isPBM, + }); + + // TODO: Once Bug 1751953 has been fixed, we can also test MODAL_TYPE_WINDOW + // here. + for (let modalType of [MODAL_TYPE_TAB, MODAL_TYPE_CONTENT]) { + let windowTypeStr = isPBM ? "private" : "normal"; + let modalTypeStr = modalType == MODAL_TYPE_TAB ? "tab" : "content"; + + info(`Opening ${modalTypeStr} prompt from ${windowTypeStr} browsing.`); + + let openPromise = PromptTestUtils.waitForPrompt( + win.gBrowser.selectedBrowser, + { + modalType, + promptType: "alert", + } + ); + let promptPromise = Services.prompt.asyncAlert( + win.gBrowser.selectedBrowser.browsingContext, + modalType, + "Hello", + "Hello, world!" + ); + + let dialog = await openPromise; + + let prefersColorScheme = await getPrefersColorSchemeInfo({ + win: dialog.ui.prompt, + chrome: true, + }); + + if (isPBM) { + ok( + !prefersColorScheme.light && prefersColorScheme.dark, + "Prompt from private window should be themed dark." + ); + } else { + ok( + prefersColorScheme.light && !prefersColorScheme.dark, + "Prompt from normal window should be themed light." + ); + } + + await PromptTestUtils.handlePrompt(dialog); + await promptPromise; + } + + await BrowserTestUtils.closeWindow(win); + } +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_persistence.js b/toolkit/components/extensions/test/browser/browser_ext_themes_persistence.js new file mode 100644 index 0000000000..b71ff572cc --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_persistence.js @@ -0,0 +1,60 @@ +"use strict"; + +// This test checks whether applied WebExtension themes are persisted and applied +// on newly opened windows. + +add_task(async function test_multiple_windows() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + let toolbox = document.querySelector("#navigator-toolbox"); + let computedStyle = window.getComputedStyle(toolbox); + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal( + docEl.getAttribute("lwtheme-brighttext"), + "true", + "LWT text color attribute should be set" + ); + Assert.ok( + computedStyle.backgroundImage.includes("image1.png"), + "Expected background image" + ); + + // Now we'll open a new window to see if the theme is also applied there. + let window2 = await BrowserTestUtils.openNewBrowserWindow(); + docEl = window2.document.documentElement; + toolbox = window2.document.querySelector("#navigator-toolbox"); + computedStyle = window.getComputedStyle(toolbox); + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal( + docEl.getAttribute("lwtheme-brighttext"), + "true", + "LWT text color attribute should be set" + ); + Assert.ok( + computedStyle.backgroundImage.includes("image1.png"), + "Expected background image" + ); + + await BrowserTestUtils.closeWindow(window2); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_reset.js b/toolkit/components/extensions/test/browser/browser_ext_themes_reset.js new file mode 100644 index 0000000000..d8b3b14073 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_reset.js @@ -0,0 +1,112 @@ +"use strict"; + +add_task(async function theme_reset_global_static_theme() { + let global_theme_extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: "#123456", + tab_background_text: "#fedcba", + }, + }, + }, + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + async background() { + await browser.theme.reset(); + let theme_after_reset = await browser.theme.getCurrent(); + + browser.test.assertEq( + "#123456", + theme_after_reset.colors.frame, + "Theme from other extension should not be cleared upon reset()" + ); + + let theme = { + colors: { + frame: "#CF723F", + }, + }; + + await browser.theme.update(theme); + await browser.theme.reset(); + let final_reset_theme = await browser.theme.getCurrent(); + + browser.test.assertEq( + JSON.stringify({ colors: null, images: null, properties: null }), + JSON.stringify(final_reset_theme), + "Should reset when extension had replaced the global theme" + ); + browser.test.sendMessage("done"); + }, + }); + await global_theme_extension.startup(); + await extension.startup(); + await extension.awaitMessage("done"); + + await global_theme_extension.unload(); + await extension.unload(); +}); + +add_task(async function theme_reset_by_windowId() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + async background() { + let theme = { + colors: { + frame: "#CF723F", + }, + }; + + let { id: winId } = await browser.windows.getCurrent(); + await browser.theme.update(winId, theme); + let update_theme = await browser.theme.getCurrent(winId); + + browser.test.onMessage.addListener(async () => { + let current_theme = await browser.theme.getCurrent(winId); + browser.test.assertEq( + update_theme.colors.frame, + current_theme.colors.frame, + "Should not be reset by a reset(windowId) call from another extension" + ); + + browser.test.sendMessage("done"); + }); + + browser.test.sendMessage("ready", winId); + }, + }); + + let anotherExtension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + background() { + browser.test.onMessage.addListener(async winId => { + await browser.theme.reset(winId); + browser.test.sendMessage("done"); + }); + }, + }); + + await extension.startup(); + let winId = await extension.awaitMessage("ready"); + + await anotherExtension.startup(); + + // theme.reset should be ignored if the theme was set by another extension. + anotherExtension.sendMessage(winId); + await anotherExtension.awaitMessage("done"); + + extension.sendMessage(); + await extension.awaitMessage("done"); + + await anotherExtension.unload(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_sanitization.js b/toolkit/components/extensions/test/browser/browser_ext_themes_sanitization.js new file mode 100644 index 0000000000..89ebd3ae68 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_sanitization.js @@ -0,0 +1,174 @@ +"use strict"; + +// This test checks color sanitization in various situations + +add_task(async function test_sanitization_invalid() { + // This test checks that invalid values are sanitized + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + bookmark_text: "ntimsfavoriteblue", + }, + }, + }, + }); + + await extension.startup(); + + let navbar = document.querySelector("#nav-bar"); + Assert.equal( + window.getComputedStyle(navbar).color, + "rgb(0, 0, 0)", + "All invalid values should always compute to black." + ); + + await extension.unload(); +}); + +add_task(async function test_sanitization_css_variables() { + // This test checks that CSS variables are sanitized + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + bookmark_text: "var(--arrowpanel-dimmed)", + }, + }, + }, + }); + + await extension.startup(); + + let navbar = document.querySelector("#nav-bar"); + Assert.equal( + window.getComputedStyle(navbar).color, + "rgb(0, 0, 0)", + "All CSS variables should always compute to black." + ); + + await extension.unload(); +}); + +add_task(async function test_sanitization_important() { + // This test checks that the sanitizer cannot be fooled with !important + let stylesheetAttr = `href="data:text/css,*{color:red!important}" type="text/css"`; + let stylesheet = document.createProcessingInstruction( + "xml-stylesheet", + stylesheetAttr + ); + let load = BrowserTestUtils.waitForEvent(stylesheet, "load"); + document.insertBefore(stylesheet, document.documentElement); + await load; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + bookmark_text: "green", + }, + }, + }, + }); + + await extension.startup(); + + let navbar = document.querySelector("#nav-bar"); + Assert.equal( + window.getComputedStyle(navbar).color, + "rgb(255, 0, 0)", + "Sheet applies" + ); + + stylesheet.remove(); + + Assert.equal( + window.getComputedStyle(navbar).color, + "rgb(0, 128, 0)", + "Shouldn't be able to fool the color sanitizer with !important" + ); + + await extension.unload(); +}); + +add_task(async function test_sanitization_transparent() { + // This test checks whether transparent values are applied properly + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_top_separator: "transparent", + }, + }, + }, + }); + + await extension.startup(); + + let navbar = document.querySelector("#nav-bar"); + Assert.ok( + window.getComputedStyle(navbar).boxShadow.includes("rgba(0, 0, 0, 0)"), + "Top separator should be transparent" + ); + + await extension.unload(); +}); + +add_task(async function test_sanitization_transparent_frame_color() { + // This test checks whether transparent frame color falls back to white. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: "transparent", + tab_background_text: TEXT_COLOR, + }, + }, + }, + }); + + await extension.startup(); + + Assert.equal( + getToolboxBackgroundColor(), + "rgb(255, 255, 255)", + "Accent color should be white" + ); + + await extension.unload(); +}); + +add_task( + async function test_sanitization_transparent_tab_background_text_color() { + // This test checks whether transparent textcolor falls back to black. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: "transparent", + }, + }, + }, + }); + + await extension.startup(); + + let docEl = document.documentElement; + Assert.equal( + window.getComputedStyle(docEl).color, + "rgb(0, 0, 0)", + "Text color should be black" + ); + + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_separators.js b/toolkit/components/extensions/test/browser/browser_ext_themes_separators.js new file mode 100644 index 0000000000..4da4927ccf --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_separators.js @@ -0,0 +1,76 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the separator colors are applied properly. + +add_task(async function test_support_separator_properties() { + const SEPARATOR_TOP_COLOR = "#ff00ff"; + const SEPARATOR_VERTICAL_COLOR = "#f0000f"; + const SEPARATOR_FIELD_COLOR = "#9400ff"; + const SEPARATOR_BOTTOM_COLOR = "#3366cc"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_top_separator: SEPARATOR_TOP_COLOR, + toolbar_vertical_separator: SEPARATOR_VERTICAL_COLOR, + // This property is deprecated, but left in to check it doesn't + // unexpectedly break the theme installation. + toolbar_field_separator: SEPARATOR_FIELD_COLOR, + toolbar_bottom_separator: SEPARATOR_BOTTOM_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + // Test the deprecated color property. + let deprecatedMessagePromise = new Promise(resolve => { + Services.console.registerListener(function listener(msg) { + if (msg.message.includes("toolbar_field_separator")) { + resolve(); + Services.console.unregisterListener(listener); + } + }); + }); + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + info("Wait for property deprecation message"); + await deprecatedMessagePromise; + + let navbar = document.querySelector("#nav-bar"); + Assert.ok( + window + .getComputedStyle(navbar) + .boxShadow.includes(`rgb(${hexToRGB(SEPARATOR_TOP_COLOR).join(", ")})`), + "Top separator color properly set" + ); + + let panelUIButton = document.querySelector("#PanelUI-button"); + // Bug 1712334: This should test bookmark item toolbar separators instead + Assert.equal( + window + .getComputedStyle(panelUIButton) + .getPropertyValue("border-image-source"), + "none", + "No vertical separator on app menu" + ); + + let toolbox = document.querySelector("#navigator-toolbox"); + Assert.equal( + window.getComputedStyle(toolbox).borderBottomColor, + `rgb(${hexToRGB(SEPARATOR_BOTTOM_COLOR).join(", ")})`, + "Bottom separator color properly set" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_sidebars.js b/toolkit/components/extensions/test/browser/browser_ext_themes_sidebars.js new file mode 100644 index 0000000000..0d2e69716d --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_sidebars.js @@ -0,0 +1,278 @@ +"use strict"; + +// This test checks whether the sidebar color properties work. + +/** + * Test whether the selected browser has the sidebar theme applied + * + * @param {object} theme that is applied + * @param {boolean} isBrightText whether the text color is light + */ +async function test_sidebar_theme(theme, isBrightText) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme, + }, + }); + + const sidebarBox = document.getElementById("sidebar-box"); + const browserRoot = document.documentElement; + const content = SidebarUI.browser.contentWindow; + const root = content.document.documentElement; + + ok( + !browserRoot.hasAttribute("lwt-sidebar"), + "Browser should not have lwt-sidebar attribute" + ); + ok( + !root.hasAttribute("lwt-sidebar"), + "Root should not have lwt-sidebar attribute" + ); + ok( + !browserRoot.hasAttribute("lwt-sidebar-highlight"), + "Browser should not have lwt-sidebar-brighttext attribute" + ); + ok( + !root.hasAttribute("lwt-sidebar-highlight"), + "Sidebar should not have lwt-sidebar-highlight attribute" + ); + + const rootCS = content.getComputedStyle(root); + const originalBackground = rootCS.backgroundColor; + const originalColor = rootCS.color; + + // ::-moz-tree-row(selected, focus) computed style can't be accessed, so we create a fake one. + const highlightCS = { + get backgroundColor() { + // Standardize to rgb like other computed style. + let color = rootCS.getPropertyValue( + "--lwt-sidebar-highlight-background-color" + ); + let [r, g, b] = color + .replace("rgba(", "") + .split(",") + .map(channel => parseInt(channel, 10)); + return `rgb(${r}, ${g}, ${b})`; + }, + + get color() { + let color = rootCS.getPropertyValue("--lwt-sidebar-highlight-text-color"); + let [r, g, b] = color + .replace("rgba(", "") + .split(",") + .map(channel => parseInt(channel, 10)); + return `rgb(${r}, ${g}, ${b})`; + }, + }; + const originalHighlightBackground = highlightCS.backgroundColor; + const originalHighlightColor = highlightCS.color; + + await extension.startup(); + + Services.ppmm.sharedData.flush(); + + const actualBackground = hexToCSS(theme.colors.sidebar) || originalBackground; + const actualColor = hexToCSS(theme.colors.sidebar_text) || originalColor; + const actualHighlightBackground = + hexToCSS(theme.colors.sidebar_highlight) || originalHighlightBackground; + const actualHighlightColor = + hexToCSS(theme.colors.sidebar_highlight_text) || originalHighlightColor; + const isCustomHighlight = !!theme.colors.sidebar_highlight_text; + const isCustomSidebar = !!theme.colors.sidebar_text; + + is( + browserRoot.hasAttribute("lwt-sidebar"), + isCustomSidebar, + `Browser should${!isCustomSidebar ? " not" : ""} have lwt-sidebar attribute` + ); + is( + root.hasAttribute("lwt-sidebar"), + isCustomSidebar, + `Sidebar should${!isCustomSidebar ? " not" : ""} have lwt-sidebar attribute` + ); + if (isCustomSidebar) { + // Quite confusingly, getAttribute() on XUL elements for attributes that + // are not present has different behavior to HTML (empty string vs. null). + is( + root.getAttribute("lwt-sidebar"), + browserRoot.getAttribute("lwt-sidebar"), + `Sidebar lwt-sidebar attribute should match browser` + ); + } + is( + browserRoot.getAttribute("lwt-sidebar") == "dark", + isBrightText, + `Browser should${ + !isBrightText ? " not" : "" + } have lwt-sidebar="dark" attribute` + ); + is( + root.hasAttribute("lwt-sidebar-highlight"), + isCustomHighlight, + `Sidebar should${ + !isCustomHighlight ? " not" : "" + } have lwt-sidebar-highlight attribute` + ); + + if (isCustomSidebar) { + const sidebarBoxCS = window.getComputedStyle(sidebarBox); + is( + sidebarBoxCS.backgroundColor, + actualBackground, + "Sidebar box background should be set." + ); + is( + sidebarBoxCS.color, + actualColor, + "Sidebar box text color should be set." + ); + } + + is( + rootCS.backgroundColor, + actualBackground, + "Sidebar background should be set." + ); + is(rootCS.color, actualColor, "Sidebar text color should be set."); + is( + highlightCS.backgroundColor, + actualHighlightBackground, + "Sidebar highlight background color should be set." + ); + is( + highlightCS.color, + actualHighlightColor, + "Sidebar highlight text color should be set." + ); + + await extension.unload(); + + Services.ppmm.sharedData.flush(); + + ok( + !browserRoot.hasAttribute("lwt-sidebar"), + "Browser should not have lwt-sidebar attribute" + ); + ok( + !root.hasAttribute("lwt-sidebar"), + "Sidebar should not have lwt-sidebar attribute" + ); + ok( + !root.hasAttribute("lwt-sidebar-highlight"), + "Sidebar should not have lwt-sidebar-highlight attribute" + ); + + is( + rootCS.backgroundColor, + originalBackground, + "Sidebar background should be reset." + ); + is(rootCS.color, originalColor, "Sidebar text color should be reset."); + is( + highlightCS.backgroundColor, + originalHighlightBackground, + "Sidebar highlight background color should be reset." + ); + is( + highlightCS.color, + originalHighlightColor, + "Sidebar highlight text color should be reset." + ); +} + +add_task(async function test_support_sidebar_colors() { + for (let command of ["viewBookmarksSidebar", "viewHistorySidebar"]) { + info("Executing command: " + command); + + await SidebarUI.show(command); + + await test_sidebar_theme( + { + colors: { + sidebar: "#fafad2", // lightgoldenrodyellow + sidebar_text: "#2f4f4f", // darkslategrey + }, + }, + false + ); + + await test_sidebar_theme( + { + colors: { + sidebar: "#8b4513", // saddlebrown + sidebar_text: "#ffa07a", // lightsalmon + }, + }, + true + ); + + await test_sidebar_theme( + { + colors: { + sidebar: "#fffafa", // snow + sidebar_text: "#663399", // rebeccapurple + sidebar_highlight: "#7cfc00", // lawngreen + sidebar_highlight_text: "#ffefd5", // papayawhip + }, + }, + false + ); + + await test_sidebar_theme( + { + colors: { + sidebar_highlight: "#a0522d", // sienna + sidebar_highlight_text: "#fff5ee", // seashell + }, + }, + false + ); + } +}); + +add_task(async function test_support_sidebar_border_color() { + const LIGHT_SALMON = "#ffa07a"; + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + sidebar_border: LIGHT_SALMON, + }, + }, + }, + }); + + await extension.startup(); + + const sidebarHeader = document.getElementById("sidebar-header"); + const sidebarHeaderCS = window.getComputedStyle(sidebarHeader); + + is( + sidebarHeaderCS.borderBottomColor, + hexToCSS(LIGHT_SALMON), + "Sidebar header border should be colored properly" + ); + + if (AppConstants.platform !== "linux") { + const sidebarSplitter = document.getElementById("sidebar-splitter"); + const sidebarSplitterCS = window.getComputedStyle(sidebarSplitter); + + is( + sidebarSplitterCS.borderInlineEndColor, + hexToCSS(LIGHT_SALMON), + "Sidebar splitter should be colored properly" + ); + + SidebarUI.reversePosition(); + + is( + sidebarSplitterCS.borderInlineStartColor, + hexToCSS(LIGHT_SALMON), + "Sidebar splitter should be colored properly after switching sides" + ); + + SidebarUI.reversePosition(); + } + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_static_onUpdated.js b/toolkit/components/extensions/test/browser/browser_ext_themes_static_onUpdated.js new file mode 100644 index 0000000000..4a6d9a92f6 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_static_onUpdated.js @@ -0,0 +1,126 @@ +"use strict"; + +// This test checks whether browser.theme.onUpdated works +// when a static theme is applied + +add_task(async function test_on_updated() { + const theme = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + const extension = ExtensionTestUtils.loadExtension({ + background() { + browser.theme.onUpdated.addListener(updateInfo => { + browser.test.sendMessage("theme-updated", updateInfo); + }); + }, + }); + + await extension.startup(); + + info("Testing update event on static theme startup"); + let updatedPromise = extension.awaitMessage("theme-updated"); + await theme.startup(); + const { theme: receivedTheme, windowId } = await updatedPromise; + Assert.ok(!windowId, "No window id in static theme update event"); + Assert.ok( + receivedTheme.images.theme_frame.includes("image1.png"), + "Theme theme_frame image should be applied" + ); + Assert.equal( + receivedTheme.colors.frame, + ACCENT_COLOR, + "Theme frame color should be applied" + ); + Assert.equal( + receivedTheme.colors.tab_background_text, + TEXT_COLOR, + "Theme tab_background_text color should be applied" + ); + + info("Testing update event on static theme unload"); + updatedPromise = extension.awaitMessage("theme-updated"); + await theme.unload(); + const updateInfo = await updatedPromise; + Assert.ok(!windowId, "No window id in static theme update event on unload"); + Assert.equal( + Object.keys(updateInfo.theme), + 0, + "unloading theme sends empty theme in update event" + ); + + await extension.unload(); +}); + +add_task(async function test_on_updated_eventpage() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.eventPages.enabled", true]], + }); + const theme = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "watcher@mochitest" } }, + background: { persistent: false }, + }, + background() { + browser.theme.onUpdated.addListener(updateInfo => { + browser.test.sendMessage("theme-updated", updateInfo); + }); + }, + }); + + await extension.startup(); + assertPersistentListeners(extension, "theme", "onUpdated", { + primed: false, + }); + + await extension.terminateBackground(); + assertPersistentListeners(extension, "theme", "onUpdated", { + primed: true, + }); + + info("Testing update event on static theme startup"); + let updatedPromise = extension.awaitMessage("theme-updated"); + await theme.startup(); + const { theme: receivedTheme, windowId } = await updatedPromise; + Assert.ok(!windowId, "No window id in static theme update event"); + Assert.ok( + receivedTheme.images.theme_frame.includes("image1.png"), + "Theme theme_frame image should be applied" + ); + await theme.unload(); + await extension.awaitMessage("theme-updated"); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_tab_line.js b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_line.js new file mode 100644 index 0000000000..e4bd8cb99b --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_line.js @@ -0,0 +1,39 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the color of the tab line are applied properly. + +add_task(async function test_support_tab_line() { + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + const TAB_LINE_COLOR = "#ff0000"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: "#000", + tab_line: TAB_LINE_COLOR, + }, + }, + }, + }); + + await extension.startup(); + + info("Checking selected tab line color"); + let selectedTab = newWin.document.querySelector(".tabbrowser-tab[selected]"); + let tab = selectedTab.querySelector(".tab-background"); + let element = tab; + let property = "outline-color"; + let computedValue = newWin.getComputedStyle(element)[property]; + let expectedColor = `rgb(${hexToRGB(TAB_LINE_COLOR).join(", ")})`; + + Assert.ok( + computedValue.includes(expectedColor), + `Tab line should be displayed in the box shadow of the tab: ${computedValue}` + ); + + await extension.unload(); + await BrowserTestUtils.closeWindow(newWin); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_tab_loading.js b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_loading.js new file mode 100644 index 0000000000..10ce77db11 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_loading.js @@ -0,0 +1,49 @@ +"use strict"; + +add_task(async function test_support_tab_loading_filling() { + const TAB_LOADING_COLOR = "#FF0000"; + + // Make sure we use the animating loading icon + await SpecialPowers.pushPrefEnv({ + set: [["ui.prefersReducedMotion", 0]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: "#000", + toolbar: "#124455", + tab_background_text: "#9400ff", + tab_loading: TAB_LOADING_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + info("Checking selected tab loading indicator colors"); + + let selectedTab = document.querySelector(".tabbrowser-tab[visuallyselected]"); + + selectedTab.setAttribute("busy", "true"); + selectedTab.setAttribute("progress", "true"); + + let throbber = selectedTab.throbber; + Assert.equal( + window.getComputedStyle(throbber, "::before").fill, + `rgb(${hexToRGB(TAB_LOADING_COLOR).join(", ")})`, + "Throbber is filled with theme color" + ); + + selectedTab.removeAttribute("busy"); + selectedTab.removeAttribute("progress"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_tab_selected.js b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_selected.js new file mode 100644 index 0000000000..3dd77ac92c --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_selected.js @@ -0,0 +1,49 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the background color of selected tab are applied correctly. + +add_task(async function test_tab_background_color_property() { + const TAB_BACKGROUND_COLOR = "#9400ff"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + tab_selected: TAB_BACKGROUND_COLOR, + }, + }, + }, + }); + + await extension.startup(); + + info("Checking selected tab color"); + + let openTab = document.querySelector(".tabbrowser-tab[visuallyselected]"); + let openTabBackground = openTab.querySelector(".tab-background"); + + let selectedTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + let selectedTabBackground = selectedTab.querySelector(".tab-background"); + + let openTabColor = window + .getComputedStyle(openTabBackground) + .getPropertyValue("background-color"); + let selectedTabColor = window + .getComputedStyle(selectedTabBackground) + .getPropertyValue("background-color"); + + Assert.equal( + selectedTabColor, + "rgb(" + hexToRGB(TAB_BACKGROUND_COLOR).join(", ") + ")", + "Selected tab background color should be set." + ); + Assert.notEqual(openTabColor, selectedTabColor); + + gBrowser.removeTab(selectedTab); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_tab_text.js b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_text.js new file mode 100644 index 0000000000..d819f3a5f1 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_text.js @@ -0,0 +1,70 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the text color of the selected tab are applied properly. + +add_task(async function test_support_tab_text_property_css_color() { + const TAB_TEXT_COLOR = "#9400ff"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + tab_text: TAB_TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + info("Checking selected tab colors"); + let selectedTab = document.querySelector(".tabbrowser-tab[selected]"); + Assert.equal( + window.getComputedStyle(selectedTab).color, + "rgb(" + hexToRGB(TAB_TEXT_COLOR).join(", ") + ")", + "Selected tab text color should be set." + ); + + await extension.unload(); +}); + +add_task(async function test_support_tab_text_chrome_array() { + const TAB_TEXT_COLOR = [148, 0, 255]; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: FRAME_COLOR, + tab_background_text: TAB_BACKGROUND_TEXT_COLOR, + tab_text: TAB_TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + info("Checking selected tab colors"); + let selectedTab = document.querySelector(".tabbrowser-tab[selected]"); + Assert.equal( + window.getComputedStyle(selectedTab).color, + "rgb(" + TAB_TEXT_COLOR.join(", ") + ")", + "Selected tab text color should be set." + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_theme_transition.js b/toolkit/components/extensions/test/browser/browser_ext_themes_theme_transition.js new file mode 100644 index 0000000000..39934200ac --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_theme_transition.js @@ -0,0 +1,48 @@ +"use strict"; + +// This test checks whether the applied theme transition effects are applied +// correctly. + +add_task(async function test_theme_transition_effects() { + const TOOLBAR = "#f27489"; + const TEXT_COLOR = "#000000"; + const TRANSITION_PROPERTY = "background-color"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + tab_background_text: TEXT_COLOR, + toolbar: TOOLBAR, + bookmark_text: TEXT_COLOR, + }, + }, + }, + }); + + await extension.startup(); + + // check transition effect for toolbars + let navbar = document.querySelector("#nav-bar"); + let navbarCS = window.getComputedStyle(navbar); + + Assert.ok( + navbarCS + .getPropertyValue("transition-property") + .includes(TRANSITION_PROPERTY), + "Transition property set for #nav-bar" + ); + + let bookmarksBar = document.querySelector("#PersonalToolbar"); + setToolbarVisibility(bookmarksBar, true, false, true); + let bookmarksBarCS = window.getComputedStyle(bookmarksBar); + + Assert.ok( + bookmarksBarCS + .getPropertyValue("transition-property") + .includes(TRANSITION_PROPERTY), + "Transition property set for #PersonalToolbar" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields.js b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields.js new file mode 100644 index 0000000000..3fd7899cdc --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields.js @@ -0,0 +1,212 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the background color and the color of the navbar text fields are applied properly. + +const { CustomizableUITestUtils } = ChromeUtils.importESModule( + "resource://testing-common/CustomizableUITestUtils.sys.mjs" +); +let gCUITestUtils = new CustomizableUITestUtils(window); + +add_setup(async function () { + await gCUITestUtils.addSearchBar(); + registerCleanupFunction(() => { + gCUITestUtils.removeSearchBar(); + }); +}); + +add_task(async function test_support_toolbar_field_properties() { + const TOOLBAR_FIELD_BACKGROUND = "#ff00ff"; + const TOOLBAR_FIELD_COLOR = "#00ff00"; + const TOOLBAR_FIELD_BORDER = "#aaaaff"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field: TOOLBAR_FIELD_BACKGROUND, + toolbar_field_text: TOOLBAR_FIELD_COLOR, + toolbar_field_border: TOOLBAR_FIELD_BORDER, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let root = document.documentElement; + // Remove the `remotecontrol` attribute since it interferes with the urlbar styling. + root.removeAttribute("remotecontrol"); + registerCleanupFunction(() => { + root.setAttribute("remotecontrol", "true"); + }); + + let fields = [ + document.querySelector("#urlbar-background"), + BrowserSearch.searchBar, + ].filter(field => { + let bounds = field.getBoundingClientRect(); + return bounds.width > 0 && bounds.height > 0; + }); + + Assert.equal(fields.length, 2, "Should be testing two elements"); + + info( + `Checking toolbar background colors and colors for ${fields.length} toolbar fields.` + ); + for (let field of fields) { + info(`Testing ${field.id || field.className}`); + Assert.equal( + window.getComputedStyle(field).backgroundColor, + hexToCSS(TOOLBAR_FIELD_BACKGROUND), + "Field background should be set." + ); + Assert.equal( + window.getComputedStyle(field).color, + hexToCSS(TOOLBAR_FIELD_COLOR), + "Field color should be set." + ); + testBorderColor(field, TOOLBAR_FIELD_BORDER); + } + + await extension.unload(); +}); + +add_task(async function test_support_toolbar_field_brighttext() { + let root = document.documentElement; + // Remove the `remotecontrol` attribute since it interferes with the urlbar styling. + root.removeAttribute("remotecontrol"); + registerCleanupFunction(() => { + root.setAttribute("remotecontrol", "true"); + }); + let urlbar = gURLBar.textbox; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field: "#fff", + toolbar_field_text: "#000", + }, + }, + }, + }); + + await extension.startup(); + + Assert.equal( + window.getComputedStyle(urlbar).color, + hexToCSS("#000000"), + "Color has been set" + ); + Assert.equal( + root.getAttribute("lwt-toolbar-field"), + "light", + "Should be light" + ); + + await extension.unload(); + + extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field: "#000", + toolbar_field_text: "#fff", + }, + }, + }, + }); + + await extension.startup(); + + Assert.equal( + window.getComputedStyle(urlbar).color, + hexToCSS("#ffffff"), + "Color has been set" + ); + Assert.equal( + root.getAttribute("lwt-toolbar-field"), + "dark", + "Should be dark" + ); + + await extension.unload(); +}); + +// Verifies that we apply the lwt-toolbar-field="dark" attribute when +// toolbar fields are dark text on a dark background. +add_task(async function test_support_toolbar_field_brighttext_dark_on_dark() { + let root = document.documentElement; + // Remove the `remotecontrol` attribute since it interferes with the urlbar styling. + root.removeAttribute("remotecontrol"); + registerCleanupFunction(() => { + root.setAttribute("remotecontrol", "true"); + }); + let urlbar = gURLBar.textbox; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field: "#000", + toolbar_field_text: "#111111", + }, + }, + }, + }); + + await extension.startup(); + + Assert.equal( + window.getComputedStyle(urlbar).color, + hexToCSS("#111111"), + "Color has been set" + ); + Assert.equal( + root.getAttribute("lwt-toolbar-field"), + "dark", + "toolbar-field color-scheme should be dark" + ); + + await extension.unload(); +}); + +add_task(async function test_no_explicit_toolbar_field_on_dark_toolbar() { + let root = document.documentElement; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: "#000", + tab_background_text: "#fff", + // Explicitly unset toolbar fields, but they default to light. + }, + }, + }, + }); + + await extension.startup(); + + Assert.equal( + root.getAttribute("lwt-toolbar-field"), + "light", + "toolbar-field color-scheme should be set and light" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields_focus.js b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields_focus.js new file mode 100644 index 0000000000..ff6af3ade7 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields_focus.js @@ -0,0 +1,107 @@ +"use strict"; + +add_setup(async function () { + // Remove the `remotecontrol` attribute since it interferes with the urlbar styling. + document.documentElement.removeAttribute("remotecontrol"); + registerCleanupFunction(() => { + document.documentElement.setAttribute("remotecontrol", "true"); + }); +}); + +add_task(async function test_toolbar_field_focus() { + const TOOLBAR_FIELD_BACKGROUND = "#FF00FF"; + const TOOLBAR_FIELD_COLOR = "#00FF00"; + const TOOLBAR_FOCUS_BACKGROUND = "#FF0000"; + const TOOLBAR_FOCUS_TEXT = "#9400FF"; + const TOOLBAR_FOCUS_BORDER = "#FFFFFF"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: "#FF0000", + tab_background_color: "#ffffff", + toolbar_field: TOOLBAR_FIELD_BACKGROUND, + toolbar_field_text: TOOLBAR_FIELD_COLOR, + toolbar_field_focus: TOOLBAR_FOCUS_BACKGROUND, + toolbar_field_text_focus: TOOLBAR_FOCUS_TEXT, + toolbar_field_border_focus: TOOLBAR_FOCUS_BORDER, + }, + }, + }, + }); + + await extension.startup(); + + info("Checking toolbar field's focus color"); + + let urlBar = document.querySelector("#urlbar-background"); + gURLBar.textbox.setAttribute("focused", "true"); + let style = window.getComputedStyle(urlBar); + + Assert.equal( + style.backgroundColor, + `rgb(${hexToRGB(TOOLBAR_FOCUS_BACKGROUND).join(", ")})`, + "Background Color is changed" + ); + Assert.equal( + style.color, + `rgb(${hexToRGB(TOOLBAR_FOCUS_TEXT).join(", ")})`, + "Text Color is changed" + ); + Assert.equal( + style.outlineColor, + `rgb(${hexToRGB(TOOLBAR_FOCUS_BORDER).join(", ")})`, + "Focus ring color" + ); + + gURLBar.textbox.removeAttribute("focused"); + + Assert.equal( + style.backgroundColor, + `rgb(${hexToRGB(TOOLBAR_FIELD_BACKGROUND).join(", ")})`, + "Background Color is set back to initial" + ); + Assert.equal( + style.color, + `rgb(${hexToRGB(TOOLBAR_FIELD_COLOR).join(", ")})`, + "Text Color is set back to initial" + ); + await extension.unload(); +}); + +add_task(async function test_toolbar_field_focus_low_alpha() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: "#FF0000", + tab_background_color: "#ffffff", + toolbar_field: "#FF00FF", + toolbar_field_text: "#00FF00", + toolbar_field_focus: "rgba(0, 0, 255, 0.4)", + toolbar_field_text_focus: "red", + toolbar_field_border_focus: "#FFFFFF", + }, + }, + }, + }); + + await extension.startup(); + gURLBar.textbox.setAttribute("focused", "true"); + + let urlBar = document.querySelector("#urlbar-background"); + Assert.equal( + window.getComputedStyle(urlBar).backgroundColor, + `rgba(0, 0, 255, 0.9)`, + "Background color has minimum opacity enforced" + ); + Assert.equal( + window.getComputedStyle(urlBar).color, + `rgb(255, 255, 255)`, + "Text color has been overridden to match background" + ); + + gURLBar.textbox.removeAttribute("focused"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_colors.js b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_colors.js new file mode 100644 index 0000000000..37c082b36f --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_colors.js @@ -0,0 +1,63 @@ +"use strict"; + +/* globals InspectorUtils */ + +// This test checks whether applied WebExtension themes that attempt to change +// the button background color properties are applied correctly. + +add_task(async function setup_home_button() { + CustomizableUI.addWidgetToArea("home-button", "nav-bar"); + registerCleanupFunction(() => + CustomizableUI.removeWidgetFromArea("home-button") + ); +}); + +add_task(async function test_button_background_properties() { + const BUTTON_BACKGROUND_ACTIVE = "#FFFFFF"; + const BUTTON_BACKGROUND_HOVER = "#59CBE8"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + button_background_active: BUTTON_BACKGROUND_ACTIVE, + button_background_hover: BUTTON_BACKGROUND_HOVER, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let toolbarButton = document.querySelector("#home-button"); + let toolbarButtonIcon = toolbarButton.icon; + let toolbarButtonIconCS = window.getComputedStyle(toolbarButtonIcon); + + InspectorUtils.addPseudoClassLock(toolbarButton, ":hover"); + + Assert.equal( + toolbarButtonIconCS.getPropertyValue("background-color"), + `rgb(${hexToRGB(BUTTON_BACKGROUND_HOVER).join(", ")})`, + "Toolbar button hover background is set." + ); + + InspectorUtils.addPseudoClassLock(toolbarButton, ":active"); + + Assert.equal( + toolbarButtonIconCS.getPropertyValue("background-color"), + `rgb(${hexToRGB(BUTTON_BACKGROUND_ACTIVE).join(", ")})`, + "Toolbar button active background is set!" + ); + + InspectorUtils.clearPseudoClassLocks(toolbarButton); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_icons.js b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_icons.js new file mode 100644 index 0000000000..2802c6ac33 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_icons.js @@ -0,0 +1,109 @@ +"use strict"; + +// This test checks applied WebExtension themes that attempt to change +// icon color properties + +add_task(async function setup_home_button() { + CustomizableUI.addWidgetToArea("home-button", "nav-bar"); + registerCleanupFunction(() => + CustomizableUI.removeWidgetFromArea("home-button") + ); +}); + +add_task(async function test_icons_properties() { + const ICONS_COLOR = "#001b47"; + const ICONS_ATTENTION_COLOR = "#44ba77"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + icons: ICONS_COLOR, + icons_attention: ICONS_ATTENTION_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let toolbarbutton = document.querySelector("#home-button"); + Assert.equal( + window.getComputedStyle(toolbarbutton).getPropertyValue("fill"), + `rgb(${hexToRGB(ICONS_COLOR).join(", ")})`, + "Buttons fill color set!" + ); + + let starButton = document.querySelector("#star-button"); + starButton.setAttribute("starred", "true"); + + let starComputedStyle = window.getComputedStyle(starButton); + Assert.equal( + starComputedStyle.getPropertyValue( + "--lwt-toolbarbutton-icon-fill-attention" + ), + `rgb(${hexToRGB(ICONS_ATTENTION_COLOR).join(", ")})`, + "Variable is properly set" + ); + Assert.equal( + starComputedStyle.getPropertyValue("fill"), + `rgb(${hexToRGB(ICONS_ATTENTION_COLOR).join(", ")})`, + "Starred icon fill is properly set" + ); + + starButton.removeAttribute("starred"); + + await extension.unload(); +}); + +add_task(async function test_no_icons_properties() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let toolbarbutton = document.querySelector("#home-button"); + let toolbarbuttonCS = window.getComputedStyle(toolbarbutton); + let currentColor = toolbarbuttonCS.getPropertyValue("color"); + Assert.equal( + window.getComputedStyle(toolbarbutton).getPropertyValue("fill"), + currentColor, + "Button fill color should be currentColor when no icon color specified." + ); + + let starButton = document.querySelector("#star-button"); + starButton.setAttribute("starred", "true"); + let starComputedStyle = window.getComputedStyle(starButton); + Assert.equal( + starComputedStyle.getPropertyValue( + "--lwt-toolbarbutton-icon-fill-attention" + ), + "", + "Icon attention fill should not be set when the value is not specified in the manifest." + ); + starButton.removeAttribute("starred"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_toolbars.js b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbars.js new file mode 100644 index 0000000000..ee31d80888 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbars.js @@ -0,0 +1,105 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the background color of toolbars are applied properly. + +add_task(async function test_support_toolbar_property() { + const TOOLBAR_COLOR = "#ff00ff"; + const TOOLBAR_TEXT_COLOR = "#9400ff"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar: TOOLBAR_COLOR, + toolbar_text: TOOLBAR_TEXT_COLOR, + }, + }, + }, + }); + + let toolbox = document.querySelector("#navigator-toolbox"); + let toolbars = [ + ...toolbox.querySelectorAll("toolbar:not(#TabsToolbar)"), + ].filter(toolbar => { + let bounds = toolbar.getBoundingClientRect(); + return bounds.width > 0 && bounds.height > 0; + }); + + let transitionPromise = waitForTransition(toolbars[0], "background-color"); + await extension.startup(); + await transitionPromise; + + info(`Checking toolbar colors for ${toolbars.length} toolbars.`); + for (let toolbar of toolbars) { + info(`Testing ${toolbar.id}`); + Assert.equal( + window.getComputedStyle(toolbar).backgroundColor, + hexToCSS(TOOLBAR_COLOR), + "Toolbar background color should be set." + ); + Assert.equal( + window.getComputedStyle(toolbar).color, + hexToCSS(TOOLBAR_TEXT_COLOR), + "Toolbar text color should be set." + ); + } + + info("Checking selected tab colors"); + let selectedTab = document.querySelector(".tabbrowser-tab[selected]"); + Assert.equal( + window.getComputedStyle(selectedTab).color, + hexToCSS(TOOLBAR_TEXT_COLOR), + "Selected tab text color should be set." + ); + + await extension.unload(); +}); + +add_task(async function test_bookmark_text_property() { + const TOOLBAR_COLOR = [255, 0, 255]; + const TOOLBAR_TEXT_COLOR = [48, 0, 255]; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar: TOOLBAR_COLOR, + bookmark_text: TOOLBAR_TEXT_COLOR, + }, + }, + }, + }); + + await extension.startup(); + + let toolbox = document.querySelector("#navigator-toolbox"); + let toolbars = [ + ...toolbox.querySelectorAll("toolbar:not(#TabsToolbar)"), + ].filter(toolbar => { + let bounds = toolbar.getBoundingClientRect(); + return bounds.width > 0 && bounds.height > 0; + }); + + info(`Checking toolbar colors for ${toolbars.length} toolbars.`); + for (let toolbar of toolbars) { + info(`Testing ${toolbar.id}`); + Assert.equal( + window.getComputedStyle(toolbar).color, + rgbToCSS(TOOLBAR_TEXT_COLOR), + "bookmark_text should be an alias for toolbar_text" + ); + } + + info("Checking selected tab colors"); + let selectedTab = document.querySelector(".tabbrowser-tab[selected]"); + Assert.equal( + window.getComputedStyle(selectedTab).color, + rgbToCSS(TOOLBAR_TEXT_COLOR), + "Selected tab text color should be set." + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_warnings.js b/toolkit/components/extensions/test/browser/browser_ext_themes_warnings.js new file mode 100644 index 0000000000..025a4073dd --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_warnings.js @@ -0,0 +1,144 @@ +"use strict"; + +const { AddonSettings } = ChromeUtils.importESModule( + "resource://gre/modules/addons/AddonSettings.sys.mjs" +); + +// This test checks that theme warnings are properly emitted. + +function waitForConsole(task, message) { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async resolve => { + SimpleTest.monitorConsole(resolve, [ + { + message: new RegExp(message), + }, + ]); + await task(); + SimpleTest.endMonitorConsole(); + }); +} + +add_setup(async function () { + SimpleTest.waitForExplicitFinish(); +}); + +add_task(async function test_static_theme() { + for (const property of ["colors", "images", "properties"]) { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + [property]: { + such_property: "much_wow", + }, + }, + }, + }); + await waitForConsole( + extension.startup, + `Unrecognized theme property found: ${property}.such_property` + ); + await extension.unload(); + } +}); + +add_task(async function test_dynamic_theme() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + background() { + browser.test.onMessage.addListener((msg, details) => { + if (msg === "update-theme") { + browser.theme.update(details).then(() => { + browser.test.sendMessage("theme-updated"); + }); + } else { + browser.theme.reset().then(() => { + browser.test.sendMessage("theme-reset"); + }); + } + }); + }, + }); + + await extension.startup(); + + for (const property of ["colors", "images", "properties"]) { + extension.sendMessage("update-theme", { + [property]: { + such_property: "much_wow", + }, + }); + await waitForConsole( + () => extension.awaitMessage("theme-updated"), + `Unrecognized theme property found: ${property}.such_property` + ); + } + + await extension.unload(); +}); + +add_task(async function test_experiments_enabled() { + info("Testing that experiments are handled correctly on nightly and deved"); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + theme: { + properties: { + such_property: "much_wow", + unknown_property: "very_unknown", + }, + }, + theme_experiment: { + properties: { + such_property: "--such-property", + }, + }, + }, + }); + if (!AddonSettings.EXPERIMENTS_ENABLED) { + await waitForConsole( + extension.startup, + "This extension is not allowed to run theme experiments" + ); + } else { + await waitForConsole( + extension.startup, + "Unrecognized theme property found: properties.unknown_property" + ); + } + await extension.unload(); +}); + +add_task(async function test_experiments_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.experiments.enabled", false]], + }); + + info( + "Testing that experiments are handled correctly when experiements pref is disabled" + ); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + properties: { + such_property: "much_wow", + }, + }, + theme_experiment: { + properties: { + such_property: "--such-property", + }, + }, + }, + }); + await waitForConsole( + extension.startup, + "This extension is not allowed to run theme experiments" + ); + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_thumbnails_bg_extension.js b/toolkit/components/extensions/test/browser/browser_ext_thumbnails_bg_extension.js new file mode 100644 index 0000000000..96a2216067 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_thumbnails_bg_extension.js @@ -0,0 +1,94 @@ +"use strict"; + +/* import-globals-from ../../../thumbnails/test/head.js */ +loadTestSubscript("../../../thumbnails/test/head.js"); + +// The service that creates thumbnails of webpages in the background loads a +// web page in the background (with several features disabled). Extensions +// should be able to observe requests, but not run content scripts. +add_task(async function test_thumbnails_background_visibility_to_extensions() { + const iframeUrl = "http://example.com/?iframe"; + const testPageUrl = bgTestPageURL({ iframe: iframeUrl }); + // ^ testPageUrl is http://mochi.test:8888/.../thumbnails_background.sjs?... + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + // ":8888" omitted due to bug 1362809. + matches: [ + "http://mochi.test/*/thumbnails_background.sjs*", + "http://example.com/?iframe*", + ], + js: ["contentscript.js"], + run_at: "document_start", + all_frames: true, + }, + ], + permissions: [ + "webRequest", + "webRequestBlocking", + "http://example.com/*", + "http://mochi.test/*", + ], + }, + files: { + "contentscript.js": () => { + // Content scripts are not expected to be run in the page of the + // thumbnail service, so this should never execute. + new Image().src = "http://example.com/?unexpected-content-script"; + browser.test.fail("Content script ran in thumbs, unexpectedly."); + }, + }, + background() { + let requests = []; + browser.webRequest.onBeforeRequest.addListener( + ({ url, tabId, frameId, type }) => { + browser.test.assertEq(-1, tabId, "Thumb page is not a tab"); + // We want to know if frameId is 0 or non-negative (or possibly -1). + if (type === "sub_frame") { + browser.test.assertTrue(frameId > 0, `frame ${frameId} for ${url}`); + } else { + browser.test.assertEq(0, frameId, `frameId for ${type} ${url}`); + } + requests.push({ type, url }); + }, + { + types: ["main_frame", "sub_frame", "image"], + urls: ["*://*/*"], + }, + ["blocking"] + ); + browser.test.onMessage.addListener(msg => { + browser.test.assertEq("get-results", msg, "expected message"); + browser.test.sendMessage("webRequest-results", requests); + }); + }, + }); + + await extension.startup(); + + ok(!thumbnailExists(testPageUrl), "Thumbnail should not be cached yet."); + + await bgCapture(testPageUrl); + ok(thumbnailExists(testPageUrl), "Thumbnail should be cached after capture"); + removeThumbnail(testPageUrl); + + extension.sendMessage("get-results"); + Assert.deepEqual( + await extension.awaitMessage("webRequest-results"), + [ + { + type: "main_frame", + url: testPageUrl, + }, + { + type: "sub_frame", + url: iframeUrl, + }, + ], + "Expected requests via webRequest" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_webNavigation_eventpage.js b/toolkit/components/extensions/test/browser/browser_ext_webNavigation_eventpage.js new file mode 100644 index 0000000000..d898cb96a4 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_webNavigation_eventpage.js @@ -0,0 +1,72 @@ +"use strict"; + +add_task(async function webnav_test_eventpage() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.eventPages.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webNavigation", "*://mochi.test/*"], + background: { persistent: false }, + }, + background() { + const EVENTS = [ + "onTabReplaced", + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", + "onErrorOccurred", + "onReferenceFragmentUpdated", + "onHistoryStateUpdated", + ]; + + for (let event of EVENTS) { + browser.webNavigation[event].addListener(() => {}); + } + browser.test.sendMessage("ready"); + }, + }); + + // onTabReplaced is never persisted, it is an empty event handler. + const EVENTS = [ + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", + "onErrorOccurred", + "onReferenceFragmentUpdated", + "onHistoryStateUpdated", + ]; + + await extension.startup(); + await extension.awaitMessage("ready"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "webNavigation", event, { + primed: false, + }); + } + + await extension.terminateBackground(); + for (let event of EVENTS) { + assertPersistentListeners(extension, "webNavigation", event, { + primed: true, + }); + } + + // wake up the background, we don't really care which event does it, + // we're just verifying the state after. + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + await extension.awaitMessage("ready"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "webNavigation", event, { + primed: false, + }); + } + + await BrowserTestUtils.closeWindow(newWin); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_webRequest_redirect_mozextension.js b/toolkit/components/extensions/test/browser/browser_ext_webRequest_redirect_mozextension.js new file mode 100644 index 0000000000..674a10a5ef --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_webRequest_redirect_mozextension.js @@ -0,0 +1,48 @@ +"use strict"; + +// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1573456 +add_task(async function test_mozextension_page_loaded_in_extension_process() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "https://example.com/*", + ], + web_accessible_resources: ["test.html"], + }, + files: { + "test.html": '', + "test.js": () => { + browser.test.assertTrue( + browser.webRequest, + "webRequest API should be available" + ); + + browser.test.sendMessage("test_done"); + }, + }, + background: () => { + browser.webRequest.onBeforeRequest.addListener( + () => { + return { + redirectUrl: browser.runtime.getURL("test.html"), + }; + }, + { urls: ["*://*/redir"] }, + ["blocking"] + ); + }, + }); + await extension.startup(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/redir" + ); + + await extension.awaitMessage("test_done"); + + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_windows_popup_title.js b/toolkit/components/extensions/test/browser/browser_ext_windows_popup_title.js new file mode 100644 index 0000000000..666d4f324f --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_windows_popup_title.js @@ -0,0 +1,133 @@ +"use strict"; + +// Check that extension popup windows contain the name of the extension +// as well as the title of the loaded document, but not the URL. +add_task(async function test_popup_title() { + const name = "custom_title_number_9_please"; + const docTitle = "popup-test-title"; + + const extensionWithImplicitHostPermission = ExtensionTestUtils.loadExtension({ + manifest: { + name, + }, + async background() { + let popup; + + // Called after the popup loads + browser.runtime.onMessage.addListener(async ({ docTitle }) => { + const name = browser.runtime.getManifest().name; + const { id } = await popup; + const { title } = await browser.windows.get(id); + + browser.test.assertTrue( + title.includes(name), + "popup title must include extension name" + ); + browser.test.assertTrue( + title.includes(docTitle), + "popup title must include extension document title" + ); + browser.test.assertFalse( + title.includes("moz-extension:"), + "popup title must not include extension URL" + ); + + // share window data with other extensions + browser.test.sendMessage("windowData", { + id: id, + fullTitle: title, + }); + + browser.test.onMessage.addListener(async message => { + if (message === "cleanup") { + await browser.windows.remove(id); + browser.test.sendMessage("finishedCleanup"); + } + }); + + browser.test.sendMessage("done"); + }); + + popup = browser.windows.create({ + url: "/index.html", + type: "popup", + }); + }, + files: { + "index.html": ` + + ${docTitle}, + + `, + "index.js": `addEventListener( + "load", + () => browser.runtime.sendMessage({docTitle: document.title}) + );`, + }, + }); + + const extensionWithoutPermissions = ExtensionTestUtils.loadExtension({ + async background() { + const { id } = await new Promise(resolve => { + browser.test.onMessage.addListener(message => { + resolve(message); + }); + }); + + const { title } = await browser.windows.get(id); + + browser.test.assertEq( + title, + undefined, + "popup window must not include title" + ); + + browser.test.sendMessage("done"); + }, + }); + + const extensionWithTabsPermission = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + async background() { + const { id, fullTitle } = await new Promise(resolve => { + browser.test.onMessage.addListener(message => { + resolve(message); + }); + }); + + const { title } = await browser.windows.get(id); + + browser.test.assertEq( + title, + fullTitle, + "popup title equals expected title" + ); + + browser.test.sendMessage("done"); + }, + }); + + await extensionWithoutPermissions.startup(); + await extensionWithTabsPermission.startup(); + await extensionWithImplicitHostPermission.startup(); + + const windowData = await extensionWithImplicitHostPermission.awaitMessage( + "windowData" + ); + + extensionWithoutPermissions.sendMessage(windowData); + extensionWithTabsPermission.sendMessage(windowData); + + await extensionWithoutPermissions.awaitMessage("done"); + await extensionWithTabsPermission.awaitMessage("done"); + await extensionWithImplicitHostPermission.awaitMessage("done"); + + extensionWithImplicitHostPermission.sendMessage("cleanup"); + await extensionWithImplicitHostPermission.awaitMessage("finishedCleanup"); + + await extensionWithoutPermissions.unload(); + await extensionWithTabsPermission.unload(); + await extensionWithImplicitHostPermission.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/data/test-download.txt b/toolkit/components/extensions/test/browser/data/test-download.txt new file mode 100644 index 0000000000..f416e0e291 --- /dev/null +++ b/toolkit/components/extensions/test/browser/data/test-download.txt @@ -0,0 +1 @@ +test download content diff --git a/toolkit/components/extensions/test/browser/data/test_downloads_referrer.html b/toolkit/components/extensions/test/browser/data/test_downloads_referrer.html new file mode 100644 index 0000000000..85410abfcd --- /dev/null +++ b/toolkit/components/extensions/test/browser/data/test_downloads_referrer.html @@ -0,0 +1,10 @@ + + + + + Test downloads referrer + + + test link + + diff --git a/toolkit/components/extensions/test/browser/head.js b/toolkit/components/extensions/test/browser/head.js new file mode 100644 index 0000000000..fc17bd5a51 --- /dev/null +++ b/toolkit/components/extensions/test/browser/head.js @@ -0,0 +1,115 @@ +/* exported ACCENT_COLOR, BACKGROUND, ENCODED_IMAGE_DATA, FRAME_COLOR, TAB_TEXT_COLOR, + TEXT_COLOR, TAB_BACKGROUND_TEXT_COLOR, imageBufferFromDataURI, hexToCSS, hexToRGB, testBorderColor, + waitForTransition, loadTestSubscript, assertPersistentListeners, getToolboxBackgroundColor */ + +"use strict"; + +const BACKGROUND = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0" + + "DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; +const ENCODED_IMAGE_DATA = + "iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAgY0h" + + "STQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAAdhwAAHYcBj+XxZQAAB5dJREFUSMd" + + "91vmTlEcZB/Bvd7/vO+/ce83O3gfLDUsC4VgIghBUEo2GM9GCFTaQBEISA1qIEVNQ4aggJDGIgAGTlFUKKcqKQpVHaQyny7FrCMiywp4ze+/Mzs67M/P" + + "O+3a3v5jdWo32H/B86vv0U083weecV3+0C8lkEh6PhzS3tuLkieMSAKo3fW9Mb1eoUtM0jemerukLllzrbGlKheovUpeqkmt113hPfx/27tyFF7+/bbg" + + "e+U9g20s7kEwmMXXGNLrp2fWi4V5z/tFjJ3fWX726INbfU2xx0yelkJAKdJf3Xl5+2QcPTpv2U0JZR+u92+xvly5ygKDm20/hlX17/jvB6VNnIKXEOyd" + + "O0iFh4PLVy0XV1U83Vk54QI7JK+bl+UE5vjRfTCzJ5eWBTFEayBLjisvljKmzwmtWrVkEAPNmVrEZkyfh+fU1n59k//7X4Fbz8MK2DRSAWLNq/Yc36y9" + + "+3UVMsyAYVPMy/MTvdBKvriJhphDq6xa9vf0i1GMwPVhM5s9bsLw/EvtN2kywwnw/nzBuLDZs2z4auXGjHuvWbmBQdT5v7qytn165fLCyyGtXTR6j5GV" + + "kIsvlBCwTVNgQhMKCRDQ2iIbmJv7BpU+Ykl02UFOzdt6gkbzTEQ5Rl2KL3W8eGUE+/ssFXK+rJQ8vWigLgjk5z9ZsvpOniJzVi+ZKTUhCuATTKCjhoLA" + + "hhQAsjrSZBJcm7rZ22O+ev6mMmTLj55eu1T+jU8GOH/kJf2TZCiifIQsXfwEbN2yktxoaeYbf93DKSORMnTOZE0aZaVlQGYVKJCgjEJSCcgLB0xDERjI" + + "NFBUEaXmuB20t95eEutr0xrufpo4eepMAkMPIxx+dx9at25EWQNXsh77q0Bzwen0ShEF32HCrCpjksAWHFAKqokFhgEJt2DKJeFoQv8eDuz3duaseXZY" + + "dixthaQ+NRlRCcKO+FgCweP68wswMF/yZWcTkNpLJFAZEGi6XC07NCUIIoqaNSLQfFALCEpCSEL/bK/wuw+12sKlDQzKs6k5yZt+rI+2aNKUSNdUbSSQ" + + "Wh2mJP46rGPeYrjtkY0M7jFgciUQCiqqgrCAfBTle3G9rR1NHN3SnDq9Lg49QlBQEcbfbQCKZlhQEDkXBih27RpDOrmacfP8YB4CfHT7uNXrCMFM2FdD" + + "BVQ5TE/A5HbDSJoSpQXAbXm8A4b5+gKrwulU4KKEBnwuzHpiQu+n1jQoQsM+9cYQMT9fvf/FLBYTaDqdzbfgft95PKzbPyQqwnlAXGkJtGIgNYnJpMfw" + + "OghLG0GJE0ZdiaOnsQ16OD6XZLkiRROdAgud5sxk8ridsy/pQU1VlOIkZN6QtAGnx0FA0AtXvIA4C5OX4kOWbiLRhQBDApTmgJuLwEonMgBvjgpmgjIE" + + "hhX7DAIVKNeqE05/dJbgEgRy5eOJ1ieXr1gJA7ZNLTrVVlAZLyopLJAUlHsrAMrwwrRQ4t6E5VHgSBExjcGpO0JQNizCE05a41dhOi+cXXVm144e1AHD" + + "1vXfFMOLy+KSHEDoEJLZ8s+ZWKpUusWwpFKiMUQ4jbiAaj8Hp9oExBsMCUpEIfD6JLKZjKJVGV3RIZGdm0qxA5qmz+/cgMhBVuuMRewRRGF7fe4BYHMg" + + "N5LxdV3vhy1EjrrjA5GAyTuKpFHricfS0dSDNCQRPoSyQgSSPI+UBEtwShiWUQEHw5mMvbz4JRcXvDr3B3dBG1sq5X53GlMcX4JWVTyvRQcOumDD2vfK" + + "cjOqiQDZPGBF2ryUEnjRhJlP4d6/BiQ1TABPKiyQhgtzvjPCJlQ/OGRwauqESSUPX68U3Vi4fGeH83Hwc3bYHBWUV0m0k4HB6z7aGu6sznDos00R3exg" + + "l5ZMwc+FMaJoKKxHFnbo6DMYiELBlqLOXDBq8dsvuPTfKALpwdbX42iMLsHjLd0Zv4RNvvY1wZxdZunyVDGZm6D/47sv12RqbmOPVhG5LGnAH4S8sgu7" + + "1oK/pn2BWAoYw0dDbaTd19iqlZROejwzEjqgMSuXUifak8jF49JnNI0kAoGrBfET7+uXOrS+y5ta21JzZsw7faW45XJaXxSvyAtTpkOi483fwtAWP1wt" + + "vrhvd/VFx+26zojr9Les2PnfaTNu4cuGvvKe9BVv3/RgARiNTpk/Hod17MWikxcqzzfhK/+1jL2xc+YQAX1ISDHLV7WTpQQaLcASzPEiB41ZrmEeHkrT" + + "Q49uz/aXn+iilLKXq/MmlS0e/jFcuX4SmaQAAKSXlnIvVy1aQ6EBMFgRyCznDpfGFwdKqirF2tu5SdIeGrkiP+KS5yb7dHtIKsnI++kP9rS8RQvjmxxe" + + "jePxD2HHwwP9FdCllurGhUbx14CAbiMc4Y2qVJqwLbo0qfpdLSilILB4Xg0mT6h7vnSWzZn9RoaynobWF3K6rk1NmzMWZ83/+37+V4a1cVg5JACYF45b" + + "FGVVWOFS2V1HUCjOdBqW0Q9fYb7N9/tcSptnldjpott8rFEXBO+f+NKrWMHL9Wu1nSUAIAaUUa59aAyE43E4X3bD8W6K5K6x1h1snRaMDJDuQf7+vrzf" + + "eG+mgfrcLHh3C79bx6wttGEqERiH/AjPohWMouv2ZAAAAAElFTkSuQmCC"; +const ACCENT_COLOR = "#a14040"; +const TEXT_COLOR = "#fac96e"; +// For testing aliases of the colors above: +const FRAME_COLOR = [71, 105, 91]; +const TAB_BACKGROUND_TEXT_COLOR = [207, 221, 192, 0.9]; + +function hexToRGB(hex) { + if (!hex) { + return null; + } + hex = parseInt(hex.indexOf("#") > -1 ? hex.substring(1) : hex, 16); + return [hex >> 16, (hex & 0x00ff00) >> 8, hex & 0x0000ff]; +} + +function rgbToCSS(rgb) { + return `rgb(${rgb.join(", ")})`; +} + +function hexToCSS(hex) { + if (!hex) { + return null; + } + return rgbToCSS(hexToRGB(hex)); +} + +function imageBufferFromDataURI(encodedImageData) { + let decodedImageData = atob(encodedImageData); + return Uint8Array.from(decodedImageData, byte => byte.charCodeAt(0)).buffer; +} + +function waitForTransition(element, propertyName) { + return BrowserTestUtils.waitForEvent( + element, + "transitionend", + false, + event => { + return event.target == element && event.propertyName == propertyName; + } + ); +} + +function getToolboxBackgroundColor() { + let toolbox = document.getElementById("navigator-toolbox"); + // Ignore any potentially ongoing transition. + toolbox.style.transitionProperty = "none"; + let color = window.getComputedStyle(toolbox).backgroundColor; + toolbox.style.transitionProperty = ""; + return color; +} + +function testBorderColor(element, expected) { + let computedStyle = window.getComputedStyle(element); + Assert.equal( + computedStyle.borderLeftColor, + hexToCSS(expected), + "Element left border color should be set." + ); + Assert.equal( + computedStyle.borderRightColor, + hexToCSS(expected), + "Element right border color should be set." + ); + Assert.equal( + computedStyle.borderTopColor, + hexToCSS(expected), + "Element top border color should be set." + ); + Assert.equal( + computedStyle.borderBottomColor, + hexToCSS(expected), + "Element bottom border color should be set." + ); +} + +function loadTestSubscript(filePath) { + Services.scriptloader.loadSubScript(new URL(filePath, gTestPath).href, this); +} + +// Persistent Listener test functionality +const { assertPersistentListeners } = ExtensionTestUtils.testAssertions; diff --git a/toolkit/components/extensions/test/browser/head_serviceworker.js b/toolkit/components/extensions/test/browser/head_serviceworker.js new file mode 100644 index 0000000000..b2f9512e5a --- /dev/null +++ b/toolkit/components/extensions/test/browser/head_serviceworker.js @@ -0,0 +1,119 @@ +"use strict"; + +/* exported assert_background_serviceworker_pref_enabled, + * getBackgroundServiceWorkerRegistration, + * getServiceWorkerInfo, getServiceWorkerState, + * waitForServiceWorkerRegistrationsRemoved, waitForServiceWorkerTerminated + */ + +async function assert_background_serviceworker_pref_enabled() { + is( + WebExtensionPolicy.backgroundServiceWorkerEnabled, + true, + "Expect extensions.backgroundServiceWorker.enabled to be true" + ); +} + +// Return the name of the enum corresponding to the worker's state (ex: "STATE_ACTIVATED") +// because nsIServiceWorkerInfo doesn't currently provide a comparable string-returning getter. +function getServiceWorkerState(workerInfo) { + const map = Object.keys(workerInfo) + .filter(k => k.startsWith("STATE_")) + .reduce((map, name) => { + map.set(workerInfo[name], name); + return map; + }, new Map()); + return map.has(workerInfo.state) + ? map.get(workerInfo.state) + : "state: ${workerInfo.state}"; +} + +function getServiceWorkerInfo(swRegInfo) { + const { evaluatingWorker, installingWorker, waitingWorker, activeWorker } = + swRegInfo; + return evaluatingWorker || installingWorker || waitingWorker || activeWorker; +} + +async function waitForServiceWorkerTerminated(swRegInfo) { + info(`Wait all ${swRegInfo.scope} workers to be terminated`); + + try { + await BrowserTestUtils.waitForCondition( + () => !getServiceWorkerInfo(swRegInfo) + ); + } catch (err) { + const workerInfo = getServiceWorkerInfo(swRegInfo); + if (workerInfo) { + ok( + false, + `Error while waiting for workers for scope ${swRegInfo.scope} to be terminated. ` + + `Found a worker in state: ${getServiceWorkerState(workerInfo)}` + ); + return; + } + + throw err; + } +} + +function getBackgroundServiceWorkerRegistration(extension) { + const policy = WebExtensionPolicy.getByHostname(extension.uuid); + const expectedSWScope = policy.getURL("/"); + const expectedScriptURL = policy.extension.backgroundWorkerScript || ""; + + ok( + expectedScriptURL.startsWith(expectedSWScope), + `Extension does include a valid background.service_worker: ${expectedScriptURL}` + ); + + const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + + let swReg; + let regs = swm.getAllRegistrations(); + + for (let i = 0; i < regs.length; i++) { + let reg = regs.queryElementAt(i, Ci.nsIServiceWorkerRegistrationInfo); + if (reg.scriptSpec === expectedScriptURL) { + swReg = reg; + break; + } + } + + ok(swReg, `Found service worker registration for ${expectedScriptURL}`); + + is( + swReg.scope, + expectedSWScope, + "The extension background worker registration has the expected scope URL" + ); + + return swReg; +} + +async function waitForServiceWorkerRegistrationsRemoved(extension) { + info(`Wait ${extension.id} service worker registration to be deleted`); + const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + let baseURI = Services.io.newURI(`moz-extension://${extension.uuid}/`); + let principal = Services.scriptSecurityManager.createContentPrincipal( + baseURI, + {} + ); + + await BrowserTestUtils.waitForCondition(() => { + let regs = swm.getAllRegistrations(); + + for (let i = 0; i < regs.length; i++) { + let reg = regs.queryElementAt(i, Ci.nsIServiceWorkerRegistrationInfo); + if (principal.equals(reg.principal)) { + return false; + } + } + + info(`All ${extension.id} service worker registrations are gone`); + return true; + }, `All ${extension.id} service worker registrations should be deleted`); +} diff --git a/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/manifest.json b/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/manifest.json new file mode 100644 index 0000000000..38a5c3f027 --- /dev/null +++ b/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/manifest.json @@ -0,0 +1,11 @@ +{ + "manifest_version": 2, + "name": "Test Extension with Background Service Worker", + "version": "1", + "browser_specific_settings": { + "gecko": { "id": "extension-with-bg-sw@test" } + }, + "background": { + "service_worker": "sw.js" + } +} diff --git a/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/sw.js b/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/sw.js new file mode 100644 index 0000000000..2282e6a64b --- /dev/null +++ b/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/sw.js @@ -0,0 +1,3 @@ +"use strict"; + +dump("extension-with-bg-sw: sw.js loaded"); diff --git a/toolkit/components/extensions/test/marionette/manifest-serviceworker.toml b/toolkit/components/extensions/test/marionette/manifest-serviceworker.toml new file mode 100644 index 0000000000..c8035f80c2 --- /dev/null +++ b/toolkit/components/extensions/test/marionette/manifest-serviceworker.toml @@ -0,0 +1,4 @@ +[DEFAULT] + +["test_extension_serviceworkers_purged_on_pref_disabled.py"] +["test_temporary_extension_serviceworkers_not_persisted.py"] diff --git a/toolkit/components/extensions/test/marionette/service_worker_testutils.py b/toolkit/components/extensions/test/marionette/service_worker_testutils.py new file mode 100644 index 0000000000..b1fda926c0 --- /dev/null +++ b/toolkit/components/extensions/test/marionette/service_worker_testutils.py @@ -0,0 +1,48 @@ +# 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/. + +from marionette_harness import MarionetteTestCase + +EXT_ID = "extension-with-bg-sw@test" +EXT_DIR_PATH = "extension-with-bg-sw" +PREF_BG_SW_ENABLED = "extensions.backgroundServiceWorker.enabled" +PREF_PERSIST_TEMP_ADDONS = ( + "dom.serviceWorkers.testing.persistTemporarilyInstalledAddons" +) + + +class MarionetteServiceWorkerTestCase(MarionetteTestCase): + def get_extension_url(self, path="/"): + with self.marionette.using_context("chrome"): + return self.marionette.execute_script( + """ + let policy = WebExtensionPolicy.getByID(arguments[0]); + return policy.getURL(arguments[1]) + """, + script_args=(self.test_extension_id, path), + ) + + @property + def is_extension_service_worker_registered(self): + with self.marionette.using_context("chrome"): + return self.marionette.execute_script( + """ + let serviceWorkerManager = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + + let serviceWorkers = serviceWorkerManager.getAllRegistrations(); + for (let i = 0; i < serviceWorkers.length; i++) { + let sw = serviceWorkers.queryElementAt( + i, + Ci.nsIServiceWorkerRegistrationInfo + ); + if (sw.scope == arguments[0]) { + return true; + } + } + return false; + """, + script_args=(self.test_extension_base_url,), + ) diff --git a/toolkit/components/extensions/test/marionette/test_extension_serviceworkers_purged_on_pref_disabled.py b/toolkit/components/extensions/test/marionette/test_extension_serviceworkers_purged_on_pref_disabled.py new file mode 100644 index 0000000000..ff2184c692 --- /dev/null +++ b/toolkit/components/extensions/test/marionette/test_extension_serviceworkers_purged_on_pref_disabled.py @@ -0,0 +1,56 @@ +# 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/. + +import os +import sys + +from marionette_driver import Wait +from marionette_driver.addons import Addons + +# Add this directory to the import path. +sys.path.append(os.path.dirname(__file__)) + +from service_worker_testutils import ( + EXT_DIR_PATH, + EXT_ID, + PREF_BG_SW_ENABLED, + PREF_PERSIST_TEMP_ADDONS, + MarionetteServiceWorkerTestCase, +) + + +class PurgeExtensionServiceWorkersOnPrefDisabled(MarionetteServiceWorkerTestCase): + def setUp(self): + super(PurgeExtensionServiceWorkersOnPrefDisabled, self).setUp() + self.test_extension_id = EXT_ID + # Flip the "mirror: once" pref and restart Firefox to be able + # to run the extension successfully. + self.marionette.set_pref(PREF_BG_SW_ENABLED, True) + self.marionette.set_pref(PREF_PERSIST_TEMP_ADDONS, True) + self.marionette.restart(in_app=True) + + def tearDown(self): + self.marionette.restart(in_app=False, clean=True) + super(PurgeExtensionServiceWorkersOnPrefDisabled, self).tearDown() + + def test_unregistering_service_worker_when_clearing_data(self): + self.install_extension_with_service_worker() + + # Flip the pref to false and restart again to verify that the + # service worker registration has been removed as expected. + self.marionette.set_pref(PREF_BG_SW_ENABLED, False) + self.marionette.restart(in_app=True) + self.assertFalse(self.is_extension_service_worker_registered) + + def install_extension_with_service_worker(self): + addons = Addons(self.marionette) + test_extension_path = os.path.join( + os.path.dirname(self.filepath), "data", EXT_DIR_PATH + ) + addons.install(test_extension_path, temp=True) + self.test_extension_base_url = self.get_extension_url() + Wait(self.marionette).until( + lambda _: self.is_extension_service_worker_registered, + message="Wait the extension service worker to be registered", + ) diff --git a/toolkit/components/extensions/test/marionette/test_temporary_extension_serviceworkers_not_persisted.py b/toolkit/components/extensions/test/marionette/test_temporary_extension_serviceworkers_not_persisted.py new file mode 100644 index 0000000000..57c0696385 --- /dev/null +++ b/toolkit/components/extensions/test/marionette/test_temporary_extension_serviceworkers_not_persisted.py @@ -0,0 +1,54 @@ +# 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/. + +import os +import sys + +from marionette_driver import Wait +from marionette_driver.addons import Addons + +# Add this directory to the import path. +sys.path.append(os.path.dirname(__file__)) + +from service_worker_testutils import ( + EXT_DIR_PATH, + EXT_ID, + PREF_BG_SW_ENABLED, + MarionetteServiceWorkerTestCase, +) + + +class TemporarilyInstalledAddonServiceWorkerNotPersisted( + MarionetteServiceWorkerTestCase +): + def setUp(self): + super(TemporarilyInstalledAddonServiceWorkerNotPersisted, self).setUp() + self.test_extension_id = EXT_ID + # Flip the "mirror: once" pref and restart Firefox to be able + # to run the extension successfully. + self.marionette.set_pref(PREF_BG_SW_ENABLED, True) + self.marionette.restart(in_app=True) + + def tearDown(self): + self.marionette.restart(in_app=False, clean=True) + super(TemporarilyInstalledAddonServiceWorkerNotPersisted, self).tearDown() + + def test_temporarily_installed_addon_serviceWorkers_not_persisted(self): + self.install_temporary_extension_with_service_worker() + # Make sure the extension worker registration is persisted + # across restarts when the pref stays set to true. + self.marionette.restart(in_app=True) + self.assertFalse(self.is_extension_service_worker_registered) + + def install_temporary_extension_with_service_worker(self): + addons = Addons(self.marionette) + test_extension_path = os.path.join( + os.path.dirname(self.filepath), "data", EXT_DIR_PATH + ) + addons.install(test_extension_path, temp=True) + self.test_extension_base_url = self.get_extension_url() + Wait(self.marionette).until( + lambda _: self.is_extension_service_worker_registered, + message="Wait the extension service worker to be registered", + ) diff --git a/toolkit/components/extensions/test/mochitest/.eslintrc.js b/toolkit/components/extensions/test/mochitest/.eslintrc.js new file mode 100644 index 0000000000..a776405c9d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/.eslintrc.js @@ -0,0 +1,12 @@ +"use strict"; + +module.exports = { + env: { + browser: true, + webextensions: true, + }, + + rules: { + "no-shadow": 0, + }, +}; diff --git a/toolkit/components/extensions/test/mochitest/chrome.toml b/toolkit/components/extensions/test/mochitest/chrome.toml new file mode 100644 index 0000000000..0fbd044f53 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/chrome.toml @@ -0,0 +1,55 @@ +[DEFAULT] +support-files = [ + "chrome_cleanup_script.js", + "head.js", + "head_cookies.js", + "file_image_good.png", + "file_image_great.png", + "file_sample.html", + "file_with_images.html", + "webrequest_chromeworker.js", + "webrequest_test.sys.mjs", +] +prefs = ["security.mixed_content.upgrade_display_content=false"] +tags = "webextensions in-process-webextensions" + +# NO NEW TESTS. mochitest-chrome does not run under e10s, avoid adding new +# tests here unless absolutely necessary. + +["test_chrome_ext_contentscript_data_uri.html"] + +["test_chrome_ext_contentscript_telemetry.html"] + +["test_chrome_ext_contentscript_unrecognizedprop_warning.html"] + +["test_chrome_ext_downloads_open.html"] + +["test_chrome_ext_downloads_saveAs.html"] +skip-if = [ + "verify && !debug && os == 'win'", + "os == 'android'", + "win10_2009", # Bug 1695612 +] + +["test_chrome_ext_downloads_uniquify.html"] +skip-if = ["win10_2009"] # Bug 1695612 + +["test_chrome_ext_permissions.html"] +skip-if = ["os == 'android'"] # Bug 1350559 + +["test_chrome_ext_svg_context_fill.html"] + +["test_chrome_ext_trackingprotection.html"] + +["test_chrome_ext_webnavigation_resolved_urls.html"] + +["test_chrome_ext_webrequest_background_events.html"] + +["test_chrome_ext_webrequest_host_permissions.html"] +skip-if = ["verify"] + +["test_chrome_ext_webrequest_mozextension.html"] +skip-if = ["true"] # Bug 1404172 + +["test_chrome_native_messaging_paths.html"] +skip-if = ["os != 'mac' && os != 'linux'"] diff --git a/toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js b/toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js new file mode 100644 index 0000000000..9afa95f302 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js @@ -0,0 +1,65 @@ +/* eslint-env mozilla/chrome-script */ + +"use strict"; + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +let listener = msg => { + void (msg instanceof Ci.nsIConsoleMessage); + dump(`Console message: ${msg}\n`); +}; + +Services.console.registerListener(listener); + +let getBrowserApp, getTabBrowser; +if (AppConstants.MOZ_BUILD_APP === "mobile/android") { + getBrowserApp = win => win.BrowserApp; + getTabBrowser = tab => tab.browser; +} else { + getBrowserApp = win => win.gBrowser; + getTabBrowser = tab => tab.linkedBrowser; +} + +function* iterBrowserWindows() { + for (let win of Services.wm.getEnumerator("navigator:browser")) { + if (!win.closed && getBrowserApp(win)) { + yield win; + } + } +} + +let initialTabs = new Map(); +for (let win of iterBrowserWindows()) { + initialTabs.set(win, new Set(getBrowserApp(win).tabs)); +} + +addMessageListener("check-cleanup", extensionId => { + Services.console.unregisterListener(listener); + + let results = { + extraWindows: [], + extraTabs: [], + }; + + for (let win of iterBrowserWindows()) { + if (initialTabs.has(win)) { + let tabs = initialTabs.get(win); + + for (let tab of getBrowserApp(win).tabs) { + if (!tabs.has(tab)) { + results.extraTabs.push(getTabBrowser(tab).currentURI.spec); + } + } + } else { + results.extraWindows.push( + Array.from(win.gBrowser.tabs, tab => getTabBrowser(tab).currentURI.spec) + ); + } + } + + initialTabs = null; + + sendAsyncMessage("cleanup-results", results); +}); diff --git a/toolkit/components/extensions/test/mochitest/chrome_head.js b/toolkit/components/extensions/test/mochitest/chrome_head.js new file mode 100644 index 0000000000..3918c74e44 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/chrome_head.js @@ -0,0 +1 @@ +"use strict"; diff --git a/toolkit/components/extensions/test/mochitest/file_WebNavigation_page1.html b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page1.html new file mode 100644 index 0000000000..663ebc6112 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page1.html @@ -0,0 +1,12 @@ + + + + + + + +
+
+ + + diff --git a/toolkit/components/extensions/test/mochitest/file_WebNavigation_page2.html b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page2.html new file mode 100644 index 0000000000..cc1acc83d6 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page2.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_WebNavigation_page3.html b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page3.html new file mode 100644 index 0000000000..a0a26a2e9d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page3.html @@ -0,0 +1,9 @@ + + + + + +click me + + + diff --git a/toolkit/components/extensions/test/mochitest/file_WebRequest_page3.html b/toolkit/components/extensions/test/mochitest/file_WebRequest_page3.html new file mode 100644 index 0000000000..24c7a42986 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_WebRequest_page3.html @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_contains_iframe.html b/toolkit/components/extensions/test/mochitest/file_contains_iframe.html new file mode 100644 index 0000000000..e905b5a224 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_contains_iframe.html @@ -0,0 +1,13 @@ + + + + +file contains iframe + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_contains_img.html b/toolkit/components/extensions/test/mochitest/file_contains_img.html new file mode 100644 index 0000000000..2b0c3137d6 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_contains_img.html @@ -0,0 +1,12 @@ + + + + +file contains img + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html b/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html new file mode 100644 index 0000000000..6c1675cb47 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab2.html b/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab2.html new file mode 100644 index 0000000000..3b102b3d67 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab2.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_contentscript_iframe.html b/toolkit/components/extensions/test/mochitest/file_contentscript_iframe.html new file mode 100644 index 0000000000..670bad1360 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_contentscript_iframe.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_green.html b/toolkit/components/extensions/test/mochitest/file_green.html new file mode 100644 index 0000000000..20755c5b56 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_green.html @@ -0,0 +1,3 @@ + +Super green test page + diff --git a/toolkit/components/extensions/test/mochitest/file_green_blue.html b/toolkit/components/extensions/test/mochitest/file_green_blue.html new file mode 100644 index 0000000000..9266b637ba --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_green_blue.html @@ -0,0 +1,16 @@ + +Upper square green, rest blue + +
diff --git a/toolkit/components/extensions/test/mochitest/file_image_bad.png b/toolkit/components/extensions/test/mochitest/file_image_bad.png new file mode 100644 index 0000000000..4c3be50847 Binary files /dev/null and b/toolkit/components/extensions/test/mochitest/file_image_bad.png differ diff --git a/toolkit/components/extensions/test/mochitest/file_image_good.png b/toolkit/components/extensions/test/mochitest/file_image_good.png new file mode 100644 index 0000000000..769c636340 Binary files /dev/null and b/toolkit/components/extensions/test/mochitest/file_image_good.png differ diff --git a/toolkit/components/extensions/test/mochitest/file_image_great.png b/toolkit/components/extensions/test/mochitest/file_image_great.png new file mode 100644 index 0000000000..769c636340 Binary files /dev/null and b/toolkit/components/extensions/test/mochitest/file_image_great.png differ diff --git a/toolkit/components/extensions/test/mochitest/file_image_redirect.png b/toolkit/components/extensions/test/mochitest/file_image_redirect.png new file mode 100644 index 0000000000..4c3be50847 Binary files /dev/null and b/toolkit/components/extensions/test/mochitest/file_image_redirect.png differ diff --git a/toolkit/components/extensions/test/mochitest/file_indexedDB.html b/toolkit/components/extensions/test/mochitest/file_indexedDB.html new file mode 100644 index 0000000000..65b7e0ad2f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_indexedDB.html @@ -0,0 +1,28 @@ + + + + + + + + This is a test page. + + diff --git a/toolkit/components/extensions/test/mochitest/file_language_fr_en.html b/toolkit/components/extensions/test/mochitest/file_language_fr_en.html new file mode 100644 index 0000000000..5e3c7b3b08 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_language_fr_en.html @@ -0,0 +1,14 @@ + + + + + + + + France is the largest country in Western Europe and the third-largest in Europe as a whole. + A accès aux chiens et aux frontaux qui lui ont été il peut consulter et modifier ses collections et exporter + Cet article concerne le pays européen aujourd’hui appelé République française. Pour d’autres usages du nom France, + Pour une aide rapide et effective, veuiller trouver votre aide dans le menu ci-dessus. + Motoring events began soon after the construction of the first successful gasoline-fueled automobiles. The quick brown fox jumps over the lazy dog. + + diff --git a/toolkit/components/extensions/test/mochitest/file_language_ja.html b/toolkit/components/extensions/test/mochitest/file_language_ja.html new file mode 100644 index 0000000000..ed07ba70e5 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_language_ja.html @@ -0,0 +1,10 @@ + + + + + + + + このペ ジでは アカウントに指定された予算の履歴を一覧にしています それぞれの項目には 予算額と特定期間のステ タスが表示されます 現在または今後の予算を設定するには + + diff --git a/toolkit/components/extensions/test/mochitest/file_language_tlh.html b/toolkit/components/extensions/test/mochitest/file_language_tlh.html new file mode 100644 index 0000000000..dd7da7bdbf --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_language_tlh.html @@ -0,0 +1,12 @@ + + + + + + + + tlhIngan maH! + Hab SoSlI' Quch! + Heghlu'meH QaQ jajvam + + diff --git a/toolkit/components/extensions/test/mochitest/file_mixed.html b/toolkit/components/extensions/test/mochitest/file_mixed.html new file mode 100644 index 0000000000..f3c7dda580 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_mixed.html @@ -0,0 +1,13 @@ + + + + + + + + +
Sample text
+ + + + diff --git a/toolkit/components/extensions/test/mochitest/file_redirect_cors_bypass.html b/toolkit/components/extensions/test/mochitest/file_redirect_cors_bypass.html new file mode 100644 index 0000000000..b8fda2369a --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_redirect_cors_bypass.html @@ -0,0 +1,30 @@ + + + + 1450965 Skip Cors Check for Early WebExtention Redirects + + +
+    Fetching ...
+  
+ + + diff --git a/toolkit/components/extensions/test/mochitest/file_redirect_data_uri.html b/toolkit/components/extensions/test/mochitest/file_redirect_data_uri.html new file mode 100644 index 0000000000..fe8e5bea44 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_redirect_data_uri.html @@ -0,0 +1,9 @@ + + + + Bug 1434357: Allow Web Request API to redirect to data: URI + + +
foo
+ + diff --git a/toolkit/components/extensions/test/mochitest/file_remote_frame.html b/toolkit/components/extensions/test/mochitest/file_remote_frame.html new file mode 100644 index 0000000000..f1b9240092 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_remote_frame.html @@ -0,0 +1,20 @@ + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_sample.html b/toolkit/components/extensions/test/mochitest/file_sample.html new file mode 100644 index 0000000000..aa1ef6e6f4 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_sample.html @@ -0,0 +1,13 @@ + + + + + +file sample + + + +
Sample text
+ + + diff --git a/toolkit/components/extensions/test/mochitest/file_sample.txt b/toolkit/components/extensions/test/mochitest/file_sample.txt new file mode 100644 index 0000000000..c02cd532b1 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_sample.txt @@ -0,0 +1 @@ +Sample \ No newline at end of file diff --git a/toolkit/components/extensions/test/mochitest/file_sample.txt^headers^ b/toolkit/components/extensions/test/mochitest/file_sample.txt^headers^ new file mode 100644 index 0000000000..cb762eff80 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_sample.txt^headers^ @@ -0,0 +1 @@ +Access-Control-Allow-Origin: * diff --git a/toolkit/components/extensions/test/mochitest/file_script_bad.js b/toolkit/components/extensions/test/mochitest/file_script_bad.js new file mode 100644 index 0000000000..c425122c71 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_script_bad.js @@ -0,0 +1,3 @@ +"use strict"; + +window.failure = true; diff --git a/toolkit/components/extensions/test/mochitest/file_script_good.js b/toolkit/components/extensions/test/mochitest/file_script_good.js new file mode 100644 index 0000000000..14e959aa5c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_script_good.js @@ -0,0 +1,12 @@ +"use strict"; + +window.success = window.success ? window.success + 1 : 1; + +{ + let scripts = document.getElementsByTagName("script"); + let url = new URL(scripts[scripts.length - 1].src); + let flag = url.searchParams.get("q"); + if (flag) { + window.postMessage(flag, "*"); + } +} diff --git a/toolkit/components/extensions/test/mochitest/file_script_redirect.js b/toolkit/components/extensions/test/mochitest/file_script_redirect.js new file mode 100644 index 0000000000..c425122c71 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_script_redirect.js @@ -0,0 +1,3 @@ +"use strict"; + +window.failure = true; diff --git a/toolkit/components/extensions/test/mochitest/file_script_xhr.js b/toolkit/components/extensions/test/mochitest/file_script_xhr.js new file mode 100644 index 0000000000..ad01f74253 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_script_xhr.js @@ -0,0 +1,9 @@ +"use strict"; + +var request = new XMLHttpRequest(); +request.open( + "get", + "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/xhr_resource", + false +); +request.send(); diff --git a/toolkit/components/extensions/test/mochitest/file_serviceWorker.html b/toolkit/components/extensions/test/mochitest/file_serviceWorker.html new file mode 100644 index 0000000000..d2b99769cc --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_serviceWorker.html @@ -0,0 +1,16 @@ + + + + + + + + This is a test page. + + diff --git a/toolkit/components/extensions/test/mochitest/file_simple_iframe_worker.html b/toolkit/components/extensions/test/mochitest/file_simple_iframe_worker.html new file mode 100644 index 0000000000..2ecc24e648 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_iframe_worker.html @@ -0,0 +1,26 @@ + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_frame.html b/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_frame.html new file mode 100644 index 0000000000..909a1f9e36 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_frame.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_subframe.html b/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_subframe.html new file mode 100644 index 0000000000..a0a437d0eb --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_subframe.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_simple_sharedworker.js b/toolkit/components/extensions/test/mochitest/file_simple_sharedworker.js new file mode 100644 index 0000000000..e8776216f1 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_sharedworker.js @@ -0,0 +1,11 @@ +"use strict"; + +self.onconnect = async evt => { + const port = evt.ports[0]; + port.onmessage = async message => { + await fetch(message.data); + self.close(); + }; + port.start(); + port.postMessage("loaded"); +}; diff --git a/toolkit/components/extensions/test/mochitest/file_simple_webrequest_worker.html b/toolkit/components/extensions/test/mochitest/file_simple_webrequest_worker.html new file mode 100644 index 0000000000..a90c4509be --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_webrequest_worker.html @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_simple_worker.js b/toolkit/components/extensions/test/mochitest/file_simple_worker.js new file mode 100644 index 0000000000..9638a8e9c2 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_worker.js @@ -0,0 +1,8 @@ +"use strict"; + +self.onmessage = async message => { + await fetch(message.data); + self.close(); +}; + +self.postMessage("loaded"); diff --git a/toolkit/components/extensions/test/mochitest/file_simple_xhr.html b/toolkit/components/extensions/test/mochitest/file_simple_xhr.html new file mode 100644 index 0000000000..1b43f804d9 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_xhr.html @@ -0,0 +1,19 @@ + + + + + + + + + + +`); + } + response.write(``); + response.finish(); + }, + DELAY, + Ci.nsITimer.TYPE_ONE_SHOT + ); +} diff --git a/toolkit/components/extensions/test/mochitest/file_streamfilter.txt b/toolkit/components/extensions/test/mochitest/file_streamfilter.txt new file mode 100644 index 0000000000..56cdd85e1d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_streamfilter.txt @@ -0,0 +1 @@ +Middle diff --git a/toolkit/components/extensions/test/mochitest/file_style_bad.css b/toolkit/components/extensions/test/mochitest/file_style_bad.css new file mode 100644 index 0000000000..8dbc8dc7a4 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_style_bad.css @@ -0,0 +1,3 @@ +#test { + color: green !important; +} diff --git a/toolkit/components/extensions/test/mochitest/file_style_good.css b/toolkit/components/extensions/test/mochitest/file_style_good.css new file mode 100644 index 0000000000..46f9774b5f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_style_good.css @@ -0,0 +1,3 @@ +#test { + color: red; +} diff --git a/toolkit/components/extensions/test/mochitest/file_style_redirect.css b/toolkit/components/extensions/test/mochitest/file_style_redirect.css new file mode 100644 index 0000000000..8dbc8dc7a4 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_style_redirect.css @@ -0,0 +1,3 @@ +#test { + color: green !important; +} diff --git a/toolkit/components/extensions/test/mochitest/file_tabs_permission_page1.html b/toolkit/components/extensions/test/mochitest/file_tabs_permission_page1.html new file mode 100644 index 0000000000..63f503ad3c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_tabs_permission_page1.html @@ -0,0 +1,10 @@ + + + + + The Title + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_tabs_permission_page2.html b/toolkit/components/extensions/test/mochitest/file_tabs_permission_page2.html new file mode 100644 index 0000000000..87ac7a2f64 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_tabs_permission_page2.html @@ -0,0 +1,11 @@ + + + + + Another Title + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_third_party.html b/toolkit/components/extensions/test/mochitest/file_third_party.html new file mode 100644 index 0000000000..fc5a326297 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_third_party.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_to_drawWindow.html b/toolkit/components/extensions/test/mochitest/file_to_drawWindow.html new file mode 100644 index 0000000000..6ebd54d9a3 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_to_drawWindow.html @@ -0,0 +1,9 @@ + + + + + + +   + + diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect.html new file mode 100644 index 0000000000..cba3043f71 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html new file mode 100644 index 0000000000..c5b436979f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html^headers^ b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html^headers^ new file mode 100644 index 0000000000..574a392a15 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html^headers^ @@ -0,0 +1 @@ +Refresh: 1;url=dummy_page.html diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_frameClientRedirect.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_frameClientRedirect.html new file mode 100644 index 0000000000..d360bcbb13 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_frameClientRedirect.html @@ -0,0 +1,12 @@ + + + + + + + +
+
+ + + diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_frameRedirect.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_frameRedirect.html new file mode 100644 index 0000000000..06dbd43741 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_frameRedirect.html @@ -0,0 +1,12 @@ + + + + + + + +
+
+ + + diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe.html new file mode 100644 index 0000000000..307990714b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe.html @@ -0,0 +1,12 @@ + + + + + + + +
+
+ + + diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page1.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page1.html new file mode 100644 index 0000000000..55bb7aa6ae --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page1.html @@ -0,0 +1,8 @@ + + + + +

page1

+ page2 + + diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page2.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page2.html new file mode 100644 index 0000000000..8f589f8bbd --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page2.html @@ -0,0 +1,7 @@ + + + + +

page2

+ + diff --git a/toolkit/components/extensions/test/mochitest/file_with_about_blank.html b/toolkit/components/extensions/test/mochitest/file_with_about_blank.html new file mode 100644 index 0000000000..af51c2e52a --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_with_about_blank.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_with_images.html b/toolkit/components/extensions/test/mochitest/file_with_images.html new file mode 100644 index 0000000000..6a3c090be2 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_with_images.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/file_with_subframes_and_embed.html b/toolkit/components/extensions/test/mochitest/file_with_subframes_and_embed.html new file mode 100644 index 0000000000..348c51f16c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_with_subframes_and_embed.html @@ -0,0 +1,21 @@ + + + +Load a bunch of iframes with subframes. +

+ + + + +

+Load an embed frame. +

+ + +

+And an object. +

+ + +

+Done. diff --git a/toolkit/components/extensions/test/mochitest/file_with_xorigin_frame.html b/toolkit/components/extensions/test/mochitest/file_with_xorigin_frame.html new file mode 100644 index 0000000000..25c60df078 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_with_xorigin_frame.html @@ -0,0 +1,6 @@ + + + + +Load a cross-origin iframe from example.net

+ diff --git a/toolkit/components/extensions/test/mochitest/head.js b/toolkit/components/extensions/test/mochitest/head.js new file mode 100644 index 0000000000..48ed27a1ab --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/head.js @@ -0,0 +1,155 @@ +"use strict"; + +/* exported AppConstants, Assert, AppTestDelegate */ + +var { AppConstants } = SpecialPowers.ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { AppTestDelegate } = SpecialPowers.ChromeUtils.importESModule( + "resource://specialpowers/AppTestDelegate.sys.mjs" +); + +{ + let chromeScript = SpecialPowers.loadChromeScript( + SimpleTest.getTestFileURL("chrome_cleanup_script.js") + ); + + SimpleTest.registerCleanupFunction(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + + chromeScript.sendAsyncMessage("check-cleanup"); + + let results = await chromeScript.promiseOneMessage("cleanup-results"); + chromeScript.destroy(); + + if (results.extraWindows.length || results.extraTabs.length) { + ok( + false, + `Test left extra windows or tabs: ${JSON.stringify(results)}\n` + ); + } + }); +} + +let Assert = { + // Cut-down version based on Assert.sys.mjs. Only supports regexp and objects as + // the expected variables. + rejects(promise, expected, msg) { + return promise.then( + () => { + ok(false, msg); + }, + actual => { + let matched = false; + if (Object.prototype.toString.call(expected) == "[object RegExp]") { + if (expected.test(actual)) { + matched = true; + } + } else if (actual instanceof expected) { + matched = true; + } + + if (matched) { + ok(true, msg); + } else { + ok(false, `Unexpected exception for "${msg}": ${actual}`); + } + } + ); + }, +}; + +/* exported waitForLoad */ + +function waitForLoad(win) { + return new Promise(resolve => { + win.addEventListener( + "load", + function () { + resolve(); + }, + { capture: true, once: true } + ); + }); +} + +/* exported loadChromeScript */ +function loadChromeScript(fn) { + let wrapper = ` +(${fn.toString()})();`; + + return SpecialPowers.loadChromeScript(new Function(wrapper)); +} + +/* exported consoleMonitor */ +let consoleMonitor = { + start(messages) { + this.chromeScript = SpecialPowers.loadChromeScript( + SimpleTest.getTestFileURL("mochitest_console.js") + ); + this.chromeScript.sendAsyncMessage("consoleStart", messages); + }, + + async finished() { + let done = this.chromeScript.promiseOneMessage("consoleDone").then(done => { + this.chromeScript.destroy(); + return done; + }); + this.chromeScript.sendAsyncMessage("waitForConsole"); + let test = await done; + ok(test.ok, test.message); + }, +}; +/* exported waitForState */ + +function waitForState(sw, state) { + return new Promise(resolve => { + if (sw.state === state) { + return resolve(); + } + sw.addEventListener("statechange", function onStateChange() { + if (sw.state === state) { + sw.removeEventListener("statechange", onStateChange); + resolve(); + } + }); + }); +} + +/* exported assertPersistentListeners */ +async function assertPersistentListeners( + extWrapper, + apiNs, + apiEvents, + expected +) { + const stringErr = await SpecialPowers.spawnChrome( + [extWrapper.id, apiNs, apiEvents, expected], + async (id, apiNs, apiEvents, expected) => { + try { + const { ExtensionTestCommon } = ChromeUtils.importESModule( + "resource://testing-common/ExtensionTestCommon.sys.mjs" + ); + const ext = { id }; + for (const event of apiEvents) { + ExtensionTestCommon.testAssertions.assertPersistentListeners( + ext, + apiNs, + event, + { + primed: expected.primed, + persisted: expected.persisted, + primedListenersCount: expected.primedListenersCount, + } + ); + } + } catch (err) { + return String(err); + } + } + ); + ok( + stringErr == undefined, + stringErr ? stringErr : `Found expected primed and persistent listeners` + ); +} diff --git a/toolkit/components/extensions/test/mochitest/head_cookies.js b/toolkit/components/extensions/test/mochitest/head_cookies.js new file mode 100644 index 0000000000..610c800c94 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/head_cookies.js @@ -0,0 +1,287 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported testCookies */ +/* import-globals-from head.js */ + +async function testCookies(options) { + // Changing the options object is a bit of a hack, but it allows us to easily + // pass an expiration date to the background script. + options.expiry = Date.now() / 1000 + 3600; + + async function background(backgroundOptions) { + // Ask the parent scope to change some cookies we may or may not have + // permission for. + let awaitChanges = new Promise(resolve => { + browser.test.onMessage.addListener(msg => { + browser.test.assertEq("cookies-changed", msg, "browser.test.onMessage"); + resolve(); + }); + }); + + let changed = []; + browser.cookies.onChanged.addListener(event => { + changed.push(`${event.cookie.name}:${event.cause}`); + }); + browser.test.sendMessage("change-cookies"); + + // Try to access some cookies in various ways. + let { url, domain, secure } = backgroundOptions; + + let failures = 0; + let tallyFailure = error => { + failures++; + }; + + try { + await awaitChanges; + + let cookie = await browser.cookies.get({ url, name: "foo" }); + browser.test.assertEq( + backgroundOptions.shouldPass, + cookie != null, + "should pass == get cookie" + ); + + let cookies = await browser.cookies.getAll({ domain }); + if (backgroundOptions.shouldPass) { + browser.test.assertEq(2, cookies.length, "expected number of cookies"); + } else { + browser.test.assertEq(0, cookies.length, "expected number of cookies"); + } + + await Promise.all([ + browser.cookies + .set({ + url, + domain, + secure, + name: "foo", + value: "baz", + expirationDate: backgroundOptions.expiry, + }) + .catch(tallyFailure), + browser.cookies + .set({ + url, + domain, + secure, + name: "bar", + value: "quux", + expirationDate: backgroundOptions.expiry, + }) + .catch(tallyFailure), + browser.cookies.remove({ url, name: "deleted" }), + ]); + + if (backgroundOptions.shouldPass) { + // The order of eviction events isn't guaranteed, so just check that + // it's there somewhere. + let evicted = changed.indexOf("evicted:evicted"); + if (evicted < 0) { + browser.test.fail("got no eviction event"); + } else { + browser.test.succeed("got eviction event"); + changed.splice(evicted, 1); + } + + browser.test.assertEq( + "x:explicit,x:overwrite,x:explicit,x:explicit,foo:overwrite,foo:explicit,bar:explicit,deleted:explicit", + changed.join(","), + "expected changes" + ); + } else { + browser.test.assertEq("", changed.join(","), "expected no changes"); + } + + if (!(backgroundOptions.shouldPass || backgroundOptions.shouldWrite)) { + browser.test.assertEq(2, failures, "Expected failures"); + } else { + browser.test.assertEq(0, failures, "Expected no failures"); + } + + browser.test.notifyPass("cookie-permissions"); + } catch (error) { + browser.test.fail(`Error: ${error} :: ${error.stack}`); + browser.test.notifyFail("cookie-permissions"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: options.permissions, + }, + + background: `(${background})(${JSON.stringify(options)})`, + }); + + let stepOne = loadChromeScript(() => { + const { addMessageListener, sendAsyncMessage } = this; + addMessageListener("options", options => { + let domain = options.domain.replace(/^\.?/, "."); + // This will be evicted after we add a fourth cookie. + Services.cookies.add( + domain, + "/", + "evicted", + "bar", + options.secure, + false, + false, + options.expiry, + {}, + Ci.nsICookie.SAMESITE_NONE, + options.url.startsWith("https") + ? Ci.nsICookie.SCHEME_HTTPS + : Ci.nsICookie.SCHEME_HTTP + ); + // This will be modified by the background script. + Services.cookies.add( + domain, + "/", + "foo", + "bar", + options.secure, + false, + false, + options.expiry, + {}, + Ci.nsICookie.SAMESITE_NONE, + options.url.startsWith("https") + ? Ci.nsICookie.SCHEME_HTTPS + : Ci.nsICookie.SCHEME_HTTP + ); + // This will be deleted by the background script. + Services.cookies.add( + domain, + "/", + "deleted", + "bar", + options.secure, + false, + false, + options.expiry, + {}, + Ci.nsICookie.SAMESITE_NONE, + options.url.startsWith("https") + ? Ci.nsICookie.SCHEME_HTTPS + : Ci.nsICookie.SCHEME_HTTP + ); + sendAsyncMessage("done"); + }); + }); + stepOne.sendAsyncMessage("options", options); + await stepOne.promiseOneMessage("done"); + stepOne.destroy(); + + await extension.startup(); + + await extension.awaitMessage("change-cookies"); + + let stepTwo = loadChromeScript(() => { + const { addMessageListener, sendAsyncMessage } = this; + addMessageListener("options", options => { + let domain = options.domain.replace(/^\.?/, "."); + + Services.cookies.add( + domain, + "/", + "x", + "y", + options.secure, + false, + false, + options.expiry, + {}, + Ci.nsICookie.SAMESITE_NONE, + options.url.startsWith("https") + ? Ci.nsICookie.SCHEME_HTTPS + : Ci.nsICookie.SCHEME_HTTP + ); + Services.cookies.add( + domain, + "/", + "x", + "z", + options.secure, + false, + false, + options.expiry, + {}, + Ci.nsICookie.SAMESITE_NONE, + options.url.startsWith("https") + ? Ci.nsICookie.SCHEME_HTTPS + : Ci.nsICookie.SCHEME_HTTP + ); + Services.cookies.remove(domain, "x", "/", {}); + sendAsyncMessage("done"); + }); + }); + stepTwo.sendAsyncMessage("options", options); + await stepTwo.promiseOneMessage("done"); + stepTwo.destroy(); + + extension.sendMessage("cookies-changed"); + + await extension.awaitFinish("cookie-permissions"); + await extension.unload(); + + let stepThree = loadChromeScript(() => { + const { addMessageListener, sendAsyncMessage, assert } = this; + let cookieSvc = Services.cookies; + + function getCookies(host) { + let cookies = []; + for (let cookie of cookieSvc.getCookiesFromHost(host, {})) { + cookies.push(cookie); + } + return cookies.sort((a, b) => a.name.localeCompare(b.name)); + } + + addMessageListener("options", options => { + let cookies = getCookies(options.domain); + + if (options.shouldPass) { + assert.equal(cookies.length, 2, "expected two cookies for host"); + + assert.equal(cookies[0].name, "bar", "correct cookie name"); + assert.equal(cookies[0].value, "quux", "correct cookie value"); + + assert.equal(cookies[1].name, "foo", "correct cookie name"); + assert.equal(cookies[1].value, "baz", "correct cookie value"); + } else if (options.shouldWrite) { + // Note: |shouldWrite| applies only when |shouldPass| is false. + // This is necessary because, unfortunately, websites (and therefore web + // extensions) are allowed to write some cookies which they're not allowed + // to read. + assert.equal(cookies.length, 3, "expected three cookies for host"); + + assert.equal(cookies[0].name, "bar", "correct cookie name"); + assert.equal(cookies[0].value, "quux", "correct cookie value"); + + assert.equal(cookies[1].name, "deleted", "correct cookie name"); + + assert.equal(cookies[2].name, "foo", "correct cookie name"); + assert.equal(cookies[2].value, "baz", "correct cookie value"); + } else { + assert.equal(cookies.length, 2, "expected two cookies for host"); + + assert.equal(cookies[0].name, "deleted", "correct second cookie name"); + + assert.equal(cookies[1].name, "foo", "correct cookie name"); + assert.equal(cookies[1].value, "bar", "correct cookie value"); + } + + for (let cookie of cookies) { + cookieSvc.remove(cookie.host, cookie.name, "/", {}); + } + // Make sure we don't silently poison subsequent tests if something goes wrong. + assert.equal(getCookies(options.domain).length, 0, "cookies cleared"); + sendAsyncMessage("done"); + }); + }); + stepThree.sendAsyncMessage("options", options); + await stepThree.promiseOneMessage("done"); + stepThree.destroy(); +} diff --git a/toolkit/components/extensions/test/mochitest/head_notifications.js b/toolkit/components/extensions/test/mochitest/head_notifications.js new file mode 100644 index 0000000000..bba3f59d49 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/head_notifications.js @@ -0,0 +1,171 @@ +"use strict"; + +/* exported MockAlertsService */ + +function mockServicesChromeScript() { + /* eslint-env mozilla/chrome-script */ + + const MOCK_ALERTS_CID = Components.ID( + "{48068bc2-40ab-4904-8afd-4cdfb3a385f3}" + ); + const ALERTS_SERVICE_CONTRACT_ID = "@mozilla.org/alerts-service;1"; + + const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" + ); + const registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); + + let activeNotifications = Object.create(null); + + const mockAlertsService = { + showPersistentNotification: function ( + persistentData, + alert, + alertListener + ) { + this.showAlert(alert, alertListener); + }, + + showAlert: function (alert, listener) { + activeNotifications[alert.name] = { + listener: listener, + cookie: alert.cookie, + title: alert.title, + }; + + // fake async alert show event + if (listener) { + setTimeout(function () { + listener.observe(null, "alertshow", alert.cookie); + }, 100); + } + }, + + showAlertNotification: function ( + imageUrl, + title, + text, + textClickable, + cookie, + alertListener, + name + ) { + this.showAlert( + { + name: name, + cookie: cookie, + title: title, + }, + alertListener + ); + }, + + closeAlert: function (name) { + let alertNotification = activeNotifications[name]; + if (alertNotification) { + if (alertNotification.listener) { + alertNotification.listener.observe( + null, + "alertfinished", + alertNotification.cookie + ); + } + delete activeNotifications[name]; + } + }, + + QueryInterface: ChromeUtils.generateQI(["nsIAlertsService"]), + + createInstance: function (iid) { + return this.QueryInterface(iid); + }, + }; + + registrar.registerFactory( + MOCK_ALERTS_CID, + "alerts service", + ALERTS_SERVICE_CONTRACT_ID, + mockAlertsService + ); + + function clickNotifications(doClose) { + // Until we need to close a specific notification, just click them all. + for (let [name, notification] of Object.entries(activeNotifications)) { + let { listener, cookie } = notification; + listener.observe(null, "alertclickcallback", cookie); + if (doClose) { + mockAlertsService.closeAlert(name); + } + } + } + + function closeAllNotifications() { + for (let alertName of Object.keys(activeNotifications)) { + mockAlertsService.closeAlert(alertName); + } + } + + const { addMessageListener, sendAsyncMessage } = this; + + addMessageListener("mock-alert-service:unregister", () => { + closeAllNotifications(); + activeNotifications = null; + registrar.unregisterFactory(MOCK_ALERTS_CID, mockAlertsService); + sendAsyncMessage("mock-alert-service:unregistered"); + }); + + addMessageListener( + "mock-alert-service:click-notifications", + clickNotifications + ); + + addMessageListener( + "mock-alert-service:close-notifications", + closeAllNotifications + ); + + sendAsyncMessage("mock-alert-service:registered"); +} + +const MockAlertsService = { + async register() { + if (this._chromeScript) { + throw new Error("MockAlertsService already registered"); + } + this._chromeScript = SpecialPowers.loadChromeScript( + mockServicesChromeScript + ); + await this._chromeScript.promiseOneMessage("mock-alert-service:registered"); + }, + async unregister() { + if (!this._chromeScript) { + throw new Error("MockAlertsService not registered"); + } + this._chromeScript.sendAsyncMessage("mock-alert-service:unregister"); + return this._chromeScript + .promiseOneMessage("mock-alert-service:unregistered") + .then(() => { + this._chromeScript.destroy(); + this._chromeScript = null; + }); + }, + async clickNotifications() { + // Most implementations of the nsIAlertsService automatically close upon click. + await this._chromeScript.sendAsyncMessage( + "mock-alert-service:click-notifications", + true + ); + }, + async clickNotificationsWithoutClose() { + // The implementation on macOS does not automatically close the notification. + await this._chromeScript.sendAsyncMessage( + "mock-alert-service:click-notifications", + false + ); + }, + async closeNotifications() { + await this._chromeScript.sendAsyncMessage( + "mock-alert-service:close-notifications" + ); + }, +}; diff --git a/toolkit/components/extensions/test/mochitest/head_unlimitedStorage.js b/toolkit/components/extensions/test/mochitest/head_unlimitedStorage.js new file mode 100644 index 0000000000..2194e156dd --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/head_unlimitedStorage.js @@ -0,0 +1,45 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported checkSitePermissions */ + +const { Services } = SpecialPowers; +const { NetUtil } = SpecialPowers.ChromeUtils.importESModule( + "resource://gre/modules/NetUtil.sys.mjs" +); + +function checkSitePermissions(uuid, expectedPermAction, assertMessage) { + if (!uuid) { + throw new Error( + "checkSitePermissions should not be called with an undefined uuid" + ); + } + + const baseURI = NetUtil.newURI(`moz-extension://${uuid}/`); + const principal = Services.scriptSecurityManager.createContentPrincipal( + baseURI, + {} + ); + + const sitePermissions = { + webextUnlimitedStorage: Services.perms.testPermissionFromPrincipal( + principal, + "WebExtensions-unlimitedStorage" + ), + persistentStorage: Services.perms.testPermissionFromPrincipal( + principal, + "persistent-storage" + ), + }; + + for (const [sitePermissionName, actualPermAction] of Object.entries( + sitePermissions + )) { + is( + actualPermAction, + expectedPermAction, + `The extension "${sitePermissionName}" SitePermission ${assertMessage} as expected` + ); + } +} diff --git a/toolkit/components/extensions/test/mochitest/head_webrequest.js b/toolkit/components/extensions/test/mochitest/head_webrequest.js new file mode 100644 index 0000000000..9e6b5cc910 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/head_webrequest.js @@ -0,0 +1,481 @@ +"use strict"; + +let commonEvents = { + onBeforeRequest: [{ urls: [""] }, ["blocking"]], + onBeforeSendHeaders: [ + { urls: [""] }, + ["blocking", "requestHeaders"], + ], + onSendHeaders: [{ urls: [""] }, ["requestHeaders"]], + onBeforeRedirect: [{ urls: [""] }], + onHeadersReceived: [ + { urls: [""] }, + ["blocking", "responseHeaders"], + ], + // Auth tests will need to set their own events object + // "onAuthRequired": [{urls: [""]}, ["blocking", "responseHeaders"]], + onResponseStarted: [{ urls: [""] }], + onCompleted: [{ urls: [""] }, ["responseHeaders"]], + onErrorOccurred: [{ urls: [""] }], +}; + +function background(events) { + const IP_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; + + let expect; + let ignore; + let defaultOrigin; + let watchAuth = Object.keys(events).includes("onAuthRequired"); + let expectedIp = null; + + browser.test.onMessage.addListener((msg, expected) => { + if (msg !== "set-expected") { + return; + } + expect = expected.expect; + defaultOrigin = expected.origin; + ignore = expected.ignore; + let promises = []; + // Initialize some stuff we'll need in the tests. + for (let entry of Object.values(expect)) { + // a place for the test infrastructure to store some state. + entry.test = {}; + // Each entry in expected gets a Promise that will be resolved in the + // last event for that entry. This will either be onCompleted, or the + // last entry if an events list was provided. + promises.push( + new Promise(resolve => { + entry.test.resolve = resolve; + }) + ); + // If events was left undefined, we're expecting all normal events we're + // listening for, exclude onBeforeRedirect and onErrorOccurred + if (entry.events === undefined) { + entry.events = Object.keys(events).filter( + name => name != "onErrorOccurred" && name != "onBeforeRedirect" + ); + } + if (entry.optional_events === undefined) { + entry.optional_events = []; + } + } + // When every expected entry has finished our test is done. + Promise.all(promises).then(() => { + browser.test.sendMessage("done"); + }); + browser.test.sendMessage("continue"); + }); + + // Retrieve the per-file/test expected values. + function getExpected(details) { + let url = new URL(details.url); + let filename = url.pathname.split("/").pop(); + if (ignore && ignore.includes(filename)) { + return; + } + let expected = expect[filename]; + if (!expected) { + browser.test.fail(`unexpected request ${filename}`); + return; + } + // Save filename for redirect verification. + expected.test.filename = filename; + return expected; + } + + // Process any test header modifications that can happen in request or response phases. + // If a test includes headers, it needs a complete header object, no undefined + // objects even if empty: + // request: { + // add: {"HeaderName": "value",}, + // modify: {"HeaderName": "value",}, + // remove: ["HeaderName",], + // }, + // response: { + // add: {"HeaderName": "value",}, + // modify: {"HeaderName": "value",}, + // remove: ["HeaderName",], + // }, + function processHeaders(phase, expected, details) { + // This should only happen once per phase [request|response]. + browser.test.assertFalse( + !!expected.test[phase], + `First processing of headers for ${phase}` + ); + expected.test[phase] = true; + + let headers = details[`${phase}Headers`]; + browser.test.assertTrue( + Array.isArray(headers), + `${phase}Headers array present` + ); + + let { add, modify, remove } = expected.headers[phase]; + + for (let name in add) { + browser.test.assertTrue( + !headers.find(h => h.name === name), + `header ${name} to be added not present yet in ${phase}Headers` + ); + let header = { name: name }; + if (name.endsWith("-binary")) { + header.binaryValue = Array.from(add[name], c => c.charCodeAt(0)); + } else { + header.value = add[name]; + } + headers.push(header); + } + + let modifiedAny = false; + for (let header of headers) { + if (header.name.toLowerCase() in modify) { + header.value = modify[header.name.toLowerCase()]; + modifiedAny = true; + } + } + browser.test.assertTrue( + modifiedAny, + `at least one ${phase}Headers element to modify` + ); + + let deletedAny = false; + for (let j = headers.length; j-- > 0; ) { + if (remove.includes(headers[j].name.toLowerCase())) { + headers.splice(j, 1); + deletedAny = true; + } + } + browser.test.assertTrue( + deletedAny, + `at least one ${phase}Headers element to delete` + ); + + return headers; + } + + // phase is request or response. + function checkHeaders(phase, expected, details) { + if (!/^https?:/.test(details.url)) { + return; + } + + let headers = details[`${phase}Headers`]; + browser.test.assertTrue( + Array.isArray(headers), + `valid ${phase}Headers array` + ); + + let { add, modify, remove } = expected.headers[phase]; + for (let name in add) { + let value = headers.find( + h => h.name.toLowerCase() === name.toLowerCase() + ).value; + browser.test.assertEq( + value, + add[name], + `header ${name} correctly injected in ${phase}Headers` + ); + } + + for (let name in modify) { + let value = headers.find( + h => h.name.toLowerCase() === name.toLowerCase() + ).value; + browser.test.assertEq( + value, + modify[name], + `header ${name} matches modified value` + ); + } + + for (let name of remove) { + let found = headers.find( + h => h.name.toLowerCase() === name.toLowerCase() + ); + browser.test.assertFalse( + !!found, + `deleted header ${name} still found in ${phase}Headers` + ); + } + } + + let listeners = { + onBeforeRequest(expected, details, result) { + // Save some values to test request consistency in later events. + browser.test.assertTrue( + details.tabId !== undefined, + `tabId ${details.tabId}` + ); + browser.test.assertTrue( + details.requestId !== undefined, + `requestId ${details.requestId}` + ); + // Validate requestId if it's already set, this happens with redirects. + if (expected.test.requestId !== undefined) { + browser.test.assertEq( + "string", + typeof expected.test.requestId, + `requestid ${expected.test.requestId} is string` + ); + browser.test.assertEq( + "string", + typeof details.requestId, + `requestid ${details.requestId} is string` + ); + browser.test.assertEq( + "number", + typeof parseInt(details.requestId, 10), + "parsed requestid is number" + ); + browser.test.assertEq( + expected.test.requestId, + details.requestId, + "redirects will keep the same requestId" + ); + } else { + // Save any values we want to validate in later events. + expected.test.requestId = details.requestId; + expected.test.tabId = details.tabId; + } + // Tests we don't need to do every event. + browser.test.assertTrue( + details.type.toUpperCase() in browser.webRequest.ResourceType, + `valid resource type ${details.type}` + ); + if (details.type == "main_frame") { + browser.test.assertEq( + 0, + details.frameId, + "frameId is zero when type is main_frame, see bug 1329299" + ); + } + }, + onBeforeSendHeaders(expected, details, result) { + if (expected.headers && expected.headers.request) { + result.requestHeaders = processHeaders("request", expected, details); + } + if (expected.redirect) { + browser.test.log(`${name} redirect request`); + result.redirectUrl = details.url.replace( + expected.test.filename, + expected.redirect + ); + } + }, + onBeforeRedirect() {}, + onSendHeaders(expected, details, result) { + if (expected.headers && expected.headers.request) { + checkHeaders("request", expected, details); + } + }, + onResponseStarted() {}, + onHeadersReceived(expected, details, result) { + let expectedStatus = expected.status || 200; + // If authentication is being requested we don't fail on the status code. + if (watchAuth && [401, 407].includes(details.statusCode)) { + expectedStatus = details.statusCode; + } + browser.test.assertEq( + expectedStatus, + details.statusCode, + `expected HTTP status received for ${details.url} ${details.statusLine}` + ); + if (expected.headers && expected.headers.response) { + result.responseHeaders = processHeaders("response", expected, details); + } + }, + onAuthRequired(expected, details, result) { + result.authCredentials = expected.authInfo; + }, + onCompleted(expected, details, result) { + // If we have already completed a GET request for this url, + // and it was found, we expect for the response to come fromCache. + // expected.cached may be undefined, force boolean. + if (typeof expected.cached === "boolean") { + let expectCached = + expected.cached && + details.method === "GET" && + details.statusCode != 404; + browser.test.assertEq( + expectCached, + details.fromCache, + "fromCache is correct" + ); + } + // We can only tell IPs for non-cached HTTP requests. + if (!details.fromCache && /^https?:/.test(details.url)) { + browser.test.assertTrue( + IP_PATTERN.test(details.ip), + `IP for ${details.url} looks IP-ish: ${details.ip}` + ); + + // We can't easily predict the IP ahead of time, so just make + // sure they're all consistent. + expectedIp = expectedIp || details.ip; + browser.test.assertEq( + expectedIp, + details.ip, + `correct ip for ${details.url}` + ); + } + if (expected.headers && expected.headers.response) { + checkHeaders("response", expected, details); + } + }, + onErrorOccurred(expected, details, result) { + if (expected.error) { + if (Array.isArray(expected.error)) { + browser.test.assertTrue( + expected.error.includes(details.error), + "expected error message received in onErrorOccurred" + ); + } else { + browser.test.assertEq( + expected.error, + details.error, + "expected error message received in onErrorOccurred" + ); + } + } + }, + }; + + function getListener(name) { + return details => { + let result = {}; + browser.test.log(`${name} ${details.requestId} ${details.url}`); + let expected = getExpected(details); + if (!expected) { + return result; + } + let expectedEvent = expected.events[0] == name; + if (expectedEvent) { + expected.events.shift(); + } else { + // e10s vs. non-e10s errors can end with either onCompleted or onErrorOccurred + expectedEvent = expected.optional_events.includes(name); + } + browser.test.assertTrue(expectedEvent, `received ${name}`); + browser.test.assertEq( + expected.type, + details.type, + "resource type is correct" + ); + browser.test.assertEq( + expected.origin || defaultOrigin, + details.originUrl, + "origin is correct" + ); + + if (name != "onBeforeRequest") { + // On events after onBeforeRequest, check the previous values. + browser.test.assertEq( + expected.test.requestId, + details.requestId, + "correct requestId" + ); + browser.test.assertEq( + expected.test.tabId, + details.tabId, + "correct tabId" + ); + } + try { + listeners[name](expected, details, result); + } catch (e) { + browser.test.fail(`unexpected webrequest failure ${name} ${e}`); + } + + if (expected.cancel && expected.cancel == name) { + browser.test.log(`${name} cancel request`); + browser.test.sendMessage("cancelled"); + result.cancel = true; + } + // If we've used up all the events for this test, resolve the promise. + // If something wrong happens and more events come through, there will be + // failures. + if (expected.events.length <= 0) { + expected.test.resolve(); + } + return result; + }; + } + + for (let [name, args] of Object.entries(events)) { + browser.test.log(`adding listener for ${name}`); + try { + browser.webRequest[name].addListener(getListener(name), ...args); + } catch (e) { + browser.test.assertTrue( + /\brequestBody\b/.test(e.message), + "Request body is unsupported" + ); + + // RequestBody is disabled in release builds. + if (!/\brequestBody\b/.test(e.message)) { + throw e; + } + + args.splice(args.indexOf("requestBody"), 1); + browser.webRequest[name].addListener(getListener(name), ...args); + } + } +} + +/* exported makeExtension */ + +function makeExtension(events = commonEvents) { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", ""], + }, + background: `(${background})(${JSON.stringify(events)})`, + }); +} + +/* exported addStylesheet */ + +function addStylesheet(file) { + let link = document.createElement("link"); + link.setAttribute("rel", "stylesheet"); + link.setAttribute("href", file); + document.body.appendChild(link); +} + +/* exported addLink */ + +function addLink(file) { + let a = document.createElement("a"); + a.setAttribute("href", file); + a.setAttribute("target", "_blank"); + a.setAttribute("rel", "opener"); + document.body.appendChild(a); + return a; +} + +/* exported addImage */ + +function addImage(file) { + let img = document.createElement("img"); + img.setAttribute("src", file); + document.body.appendChild(img); +} + +/* exported addScript */ + +function addScript(file) { + let script = document.createElement("script"); + script.setAttribute("type", "text/javascript"); + script.setAttribute("src", file); + document.getElementsByTagName("head").item(0).appendChild(script); +} + +/* exported addFrame */ + +function addFrame(file) { + let frame = document.createElement("iframe"); + frame.setAttribute("width", "200"); + frame.setAttribute("height", "200"); + frame.setAttribute("src", file); + document.body.appendChild(frame); +} diff --git a/toolkit/components/extensions/test/mochitest/hsts.sjs b/toolkit/components/extensions/test/mochitest/hsts.sjs new file mode 100644 index 0000000000..52b9dd340b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/hsts.sjs @@ -0,0 +1,10 @@ +"use strict"; + +function handleRequest(request, response) { + let page = "

HSTS page

"; + response.setStatusLine(request.httpVersion, "200", "OK"); + response.setHeader("Strict-Transport-Security", "max-age=60"); + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Content-Length", page.length + "", false); + response.write(page); +} diff --git a/toolkit/components/extensions/test/mochitest/mochitest-common.toml b/toolkit/components/extensions/test/mochitest/mochitest-common.toml new file mode 100644 index 0000000000..51a851a74b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/mochitest-common.toml @@ -0,0 +1,594 @@ +[DEFAULT] +tags = "condprof" +support-files = [ + "chrome_cleanup_script.js", + "file_WebNavigation_page1.html", + "file_WebNavigation_page2.html", + "file_WebNavigation_page3.html", + "file_WebRequest_page3.html", + "file_contains_img.html", + "file_contains_iframe.html", + "file_green.html", + "file_green_blue.html", + "file_contentscript_activeTab.html", + "file_contentscript_activeTab2.html", + "file_contentscript_iframe.html", + "file_image_bad.png", + "file_image_good.png", + "file_image_great.png", + "file_image_redirect.png", + "file_indexedDB.html", + "file_mixed.html", + "file_remote_frame.html", + "file_sample.html", + "file_sample.txt", + "file_sample.txt^headers^", + "file_script_bad.js", + "file_script_good.js", + "file_script_redirect.js", + "file_script_xhr.js", + "file_serviceWorker.html", + "file_simple_iframe_worker.html", + "file_simple_sandboxed_frame.html", + "file_simple_sandboxed_subframe.html", + "file_simple_xhr.html", + "file_simple_xhr_frame.html", + "file_simple_xhr_frame2.html", + "file_simple_sharedworker.js", + "file_simple_webrequest_worker.html", + "file_simple_worker.js", + "file_slowed_document.sjs", + "file_streamfilter.txt", + "file_style_bad.css", + "file_style_good.css", + "file_style_redirect.css", + "file_third_party.html", + "file_to_drawWindow.html", + "file_webNavigation_clientRedirect.html", + "file_webNavigation_clientRedirect_httpHeaders.html", + "file_webNavigation_clientRedirect_httpHeaders.html^headers^", + "file_webNavigation_frameClientRedirect.html", + "file_webNavigation_frameRedirect.html", + "file_webNavigation_manualSubframe.html", + "file_webNavigation_manualSubframe_page1.html", + "file_webNavigation_manualSubframe_page2.html", + "file_with_about_blank.html", + "file_with_subframes_and_embed.html", + "file_with_xorigin_frame.html", + "head.js", + "head_cookies.js", + "head_notifications.js", + "head_unlimitedStorage.js", + "head_webrequest.js", + "hsts.sjs", + "mochitest_console.js", + "oauth.html", + "redirect_auto.sjs", + "redirection.sjs", + "return_headers.sjs", + "serviceWorker.js", + "slow_response.sjs", + "webrequest_worker.js", + "!/dom/tests/mochitest/geolocation/network_geolocation.sjs", + "!/toolkit/components/passwordmgr/test/authenticate.sjs", + "file_redirect_data_uri.html", + "file_redirect_cors_bypass.html", + "file_tabs_permission_page1.html", + "file_tabs_permission_page2.html", + "file_language_fr_en.html", + "file_language_ja.html", + "file_language_tlh.html", +] +prefs = [ + "security.mixed_content.upgrade_display_content=false", + "browser.chrome.guess_favicon=true", +] + +["test_check_startupcache.html"] + +["test_ext_action.html"] + +["test_ext_activityLog.html"] +skip-if = [ + "os == 'android'", # Bug 1845604: test case uses tabHide permission which is not available on Android + "tsan", # Times out on TSan, bug 1612707 + "xorigin", # Inconsistent pass/fail in opt and debug + "http3", + "http2", +] + +["test_ext_async_clipboard.html"] +skip-if = [ + "os == 'android'", # Bug 1845607 + "tsan", # Bug 1612707: times out on TSan + "display == 'wayland' && os_version == '22.04'", # Bug 1857067 +] + +["test_ext_background_canvas.html"] + +["test_ext_background_page.html"] +skip-if = ["os == 'android'"] # test case covering desktop-only expected behavior (android doesn't have devtools) + +["test_ext_background_page_dpi.html"] + +["test_ext_browserAction_getUserSettings.html"] + +["test_ext_browserAction_onClicked.html"] + +["test_ext_browserAction_openPopup.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_browserAction_openPopup_incognito_window.html"] +skip-if = ["os == 'android'"] # cannot open private windows - bug 1372178 + +["test_ext_browserAction_openPopup_windowId.html"] +skip-if = ["os == 'android'"] # only the current window is supported - bug 1795956 + +["test_ext_browserAction_openPopup_without_pref.html"] + +["test_ext_browserSettings_overrideDocumentColors.html"] + +["test_ext_browsingData_indexedDB.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_browsingData_localStorage.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_browsingData_pluginData.html"] + +["test_ext_browsingData_serviceWorkers.html"] +skip-if = [ + "condprof", # "Wait for 2 service workers to be registered - timed out after 50 tries." + "http3", + "http2", +] + +["test_ext_browsingData_settings.html"] + +["test_ext_canvas_resistFingerprinting.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_clipboard.html"] +skip-if = [ + "os == 'android'", # Bug 1845607 + "http3", + "http2", +] + +["test_ext_clipboard_image.html"] +skip-if = ["headless"] # Bug 1405872 + +["test_ext_contentscript_about_blank.html"] + +["test_ext_contentscript_activeTab.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_contentscript_cache.html"] +skip-if = [ + "os == 'linux' && debug", + "os == 'android' && debug", # bug 1348241 +] +fail-if = ["xorigin"] # TypeError: can't access property "staticScripts", ext is undefined - Should not throw any errors + +["test_ext_contentscript_canvas.html"] +skip-if = [ + "os == 'android'", # Bug 1617062 + "verify && debug && os == 'linux'", +] + +["test_ext_contentscript_devtools_metadata.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_contentscript_fission_frame.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_contentscript_getFrameId.html"] + +["test_ext_contentscript_incognito.html"] +skip-if = [ + "os == 'android'", # Bug 1513544 (GeckoView is missing the windows API and ability to open private tabs) + "http3", + "http2", +] + +["test_ext_contentscript_permission.html"] +skip-if = ["tsan"] # Times out on TSan, bug 1612707 + +["test_ext_contentscript_securecontext.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_cookies.html"] +skip-if = [ + "os == 'android'", # Bug 1845615 + "tsan", # Times out on TSan intermittently, bug 1615184; + "condprof", #: "one tabId returned for store - Expected: 1, Actual: 3" + "http3", + "http2", +] + +["test_ext_cookies_containers.html"] + +["test_ext_cookies_expiry.html"] + +["test_ext_cookies_first_party.html"] + +["test_ext_cookies_incognito.html"] +skip-if = ["os == 'android'"] # Bug 1513544 (GeckoView is missing the windows API and ability to open private tabs) + +["test_ext_cookies_permissions_bad.html"] + +["test_ext_cookies_permissions_good.html"] + +["test_ext_dnr_other_extensions.html"] + +["test_ext_dnr_tabIds.html"] + +["test_ext_dnr_upgradeScheme.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_downloads_download.html"] + +["test_ext_embeddedimg_iframe_frameAncestors.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_exclude_include_globs.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_extension_iframe_messaging.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_external_messaging.html"] + +["test_ext_generate.html"] + +["test_ext_geolocation.html"] +skip-if = ["os == 'android'"] # Bug 1336194 (GeckoView doesn't yet account for geolocation to be granted for extensions requesting it from their manifest) + +["test_ext_identity.html"] +skip-if = [ + "win11_2009 && !debug && socketprocess_networking", # Bug 1777016 + "os == 'android'", # Bug 1475887 (API not supported on android yet) + "tsan", # Bug 1612707 +] + +["test_ext_idle.html"] +skip-if = ["tsan"] # Times out on TSan, bug 1612707 + +["test_ext_inIncognitoContext_window.html"] +skip-if = ["os == 'android'"] # Bug 1513544 (GeckoView is missing the windows API and ability to open private tabs) + +["test_ext_listener_proxies.html"] + +["test_ext_new_tab_processType.html"] +skip-if = [ + "verify && debug && (os == 'linux' || os == 'mac')", + "condprof", #: Page URL should match - got "https://example.com/tests/toolkit/components/extensions/test/mochitest/file_serviceWorker.html", expected "https://example.com/" + "http3", + "http2", +] + +["test_ext_notifications.html"] +skip-if = ["os == 'android'"] # Bug 1845617 + +["test_ext_optional_permissions.html"] + +["test_ext_pageAction_onClicked.html"] + +["test_ext_protocolHandlers.html"] +skip-if = ["os == 'android'"] # Bug 1342577: not implemented on GeckoView yet + +["test_ext_redirect_jar.html"] +skip-if = ["os == 'win' && (debug || asan)"] # Bug 1563440 + +["test_ext_request_urlClassification.html"] +skip-if = [ + "os == 'android'", # Bug 1615427 + "http3", + "http2", +] + +["test_ext_runtime_connect.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_runtime_connect2.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_runtime_connect_iframe.html"] + +["test_ext_runtime_connect_twoway.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_runtime_disconnect.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_script_filenames.html"] + +["test_ext_scripting_contentScripts.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_scripting_executeScript.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_scripting_executeScript_activeTab.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_scripting_executeScript_injectImmediately.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_scripting_insertCSS.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_scripting_permissions.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_scripting_removeCSS.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_sendmessage_doublereply.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_sendmessage_frameId.html"] + +["test_ext_sendmessage_no_receiver.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_sendmessage_reply.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_sendmessage_reply2.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_storage_manager_capabilities.html"] +skip-if = [ + "xorigin", # JavaScript Error: "SecurityError: Permission denied to access property "wrappedJSObject" on cross-origin object" {file: "https://example.com/tests/SimpleTest/TestRunner.js" line: 157} + "http3", + "http2", +] +scheme = "https" + +["test_ext_storage_smoke_test.html"] + +["test_ext_streamfilter_multiple.html"] +skip-if = [ + "!debug", # Bug 1628642 + "os == 'linux'", # Bug 1628642 +] + +["test_ext_streamfilter_processswitch.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_subframes_privileges.html"] +skip-if = [ + "os == 'android'", # Bug 1845918 + "verify", # Bug 1489771 + "http3", + "http2", +] + +["test_ext_tabs_captureTab.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_tabs_create_cookieStoreId.html"] + +["test_ext_tabs_detectLanguage.html"] + +["test_ext_tabs_executeScript_good.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_tabs_permissions.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_tabs_query_popup.html"] + +["test_ext_tabs_sendMessage.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_test.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_unlimitedStorage.html"] + +["test_ext_web_accessible_incognito.html"] +skip-if = ["os == 'android'"] # bug 1397615, bug 1513544 + +["test_ext_web_accessible_resources.html"] +skip-if = [ + "os == 'android' && debug", # bug 1397615 + "os == 'linux' && bits == 64", # bug 1618231 +] + +["test_ext_webnavigation.html"] +skip-if = [ + "os == 'android' && debug", # bug 1397615 + "http3", + "http2", +] + +["test_ext_webnavigation_filters.html"] +skip-if = [ + "os == 'android' && debug", + "verify && (os == 'linux' || os == 'mac')", # bug 1397615 + "http3", + "http2", +] + +["test_ext_webnavigation_incognito.html"] +skip-if = [ + "os == 'android'", # Bug 1513544 (GeckoView is missing the windows API and ability to open private tabs) + "http3", + "http2", +] + +["test_ext_webrequest_and_proxy_filter.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_webrequest_auth.html"] +skip-if = [ + "os == 'android'", # Bug 1845906 (skip-if added for Fennec) + "http3", + "http2", +] + +["test_ext_webrequest_background_events.html"] + +["test_ext_webrequest_basic.html"] +skip-if = [ + "os == 'android' && debug", # bug 1397615 + "tsan", # bug 1612707 + "xorigin", # JavaScript Error: "SecurityError: Permission denied to access property "wrappedJSObject" on cross-origin object" {file: "http://mochi.false-test:8888/tests/SimpleTest/TestRunner.js" line: 157}] + "os == 'linux' && bits == 64 && !debug && asan", # Bug 1633189 + "http3", + "http2", +] + +["test_ext_webrequest_errors.html"] +skip-if = [ + "tsan", + "http3", + "http2", +] + +["test_ext_webrequest_filter.html"] +skip-if = [ + "os == 'android' && debug", # bug 1452348 + "tsan", # tsan: bug 1612707 + "os == 'linux' && bits == 64 && !debug && xorigin", # Bug 1756023 +] + +["test_ext_webrequest_frameId.html"] + +["test_ext_webrequest_getSecurityInfo.html"] +skip-if = [ + "http3", + "http2", +] + +["test_ext_webrequest_hsts.html"] +https_first_disabled = true +skip-if = [ + "http3", + "http2", +] + +["test_ext_webrequest_redirect_bypass_cors.html"] + +["test_ext_webrequest_redirect_data_uri.html"] + +["test_ext_webrequest_upgrade.html"] +https_first_disabled = true + +["test_ext_webrequest_upload.html"] +skip-if = ["os == 'android'"] # Bug 1845906 (skip-if added for Fennec) + +["test_ext_webrequest_worker.html"] + +["test_ext_window_postMessage.html"] +skip-if = [ + "http3", + "http2", +] + +["test_startup_canary.html"] +# test_startup_canary.html is at the bottom to minimize the time spent waiting in the test. diff --git a/toolkit/components/extensions/test/mochitest/mochitest-remote.toml b/toolkit/components/extensions/test/mochitest/mochitest-remote.toml new file mode 100644 index 0000000000..4c7effd77d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/mochitest-remote.toml @@ -0,0 +1,12 @@ +[DEFAULT] +tags = "webextensions remote-webextensions" +prefs = [ + "extensions.webextensions.remote=true", + # We don't want to reset this at the end of the test, so that we don't have + # to spawn a new extension child process for each test unit. + "dom.ipc.keepProcessesAlive.extension=1", +] + +["include:mochitest-common.toml"] + +["test_verify_remote_mode.html"] diff --git a/toolkit/components/extensions/test/mochitest/mochitest-serviceworker.toml b/toolkit/components/extensions/test/mochitest/mochitest-serviceworker.toml new file mode 100644 index 0000000000..0e9e58307d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/mochitest-serviceworker.toml @@ -0,0 +1,34 @@ +[DEFAULT] +tags = "webextensions sw-webextensions condprof" +skip-if = [ + "!e10s", # Thunderbird does still run in non e10s mode (and so also with in-process-webextensions mode) + "http3", + "http2", +] + +prefs = [ + "extensions.webextensions.remote=true", + # We don't want to reset this at the end of the test, so that we don't have + # to spawn a new extension child process for each test unit. + "dom.ipc.keepProcessesAlive.extension=1", + "extensions.backgroundServiceWorker.enabled=true", + "extensions.backgroundServiceWorker.forceInTestExtension=true", +] +dupe-manifest = true + +["test_verify_sw_mode.html"] +# `test_verify_sw_mode.html` should be the first one, even if it breaks the +# alphabetical order. + +["test_ext_scripting_contentScripts.html"] + +["test_ext_scripting_executeScript.html"] +skip-if = ["true"] # Bug 1748315 - Add WebIDL bindings for `scripting.executeScript()` + +["test_ext_scripting_insertCSS.html"] +skip-if = ["true"] # Bug 1748318 - Add WebIDL bindings for `tabs` + +["test_ext_scripting_removeCSS.html"] +skip-if = ["true"] # Bug 1748318 - Add WebIDL bindings for `tabs` + +["test_ext_test.html"] diff --git a/toolkit/components/extensions/test/mochitest/mochitest.toml b/toolkit/components/extensions/test/mochitest/mochitest.toml new file mode 100644 index 0000000000..2a19953acb --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/mochitest.toml @@ -0,0 +1,15 @@ +[DEFAULT] +tags = "webextensions in-process-webextensions" +prefs = [ + "extensions.webextensions.remote=false", + "javascript.options.asyncstack_capture_debuggee_only=false", +] +dupe-manifest = true + +["include:mochitest-common.toml"] +skip-if = ["os == 'win'"] # Windows WebExtensions always run OOP + +["test_ext_storage_cleanup.html"] +# Bug 1426514 storage_cleanup: clearing localStorage fails with oop + +["test_verify_non_remote_mode.html"] diff --git a/toolkit/components/extensions/test/mochitest/mochitest_console.js b/toolkit/components/extensions/test/mochitest/mochitest_console.js new file mode 100644 index 0000000000..582e12b48f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/mochitest_console.js @@ -0,0 +1,54 @@ +/* eslint-env mozilla/chrome-script */ + +"use strict"; + +const { addMessageListener, sendAsyncMessage } = this; + +// Much of the console monitoring code is copied from TestUtils but simplified +// to our needs. +function monitorConsole(msgs) { + function msgMatches(msg, pat) { + for (let k in pat) { + if (!(k in msg)) { + return false; + } + if (pat[k] instanceof RegExp && typeof msg[k] === "string") { + if (!pat[k].test(msg[k])) { + return false; + } + } else if (msg[k] !== pat[k]) { + return false; + } + } + return true; + } + + let counter = 0; + function listener(msg) { + if (msgMatches(msg, msgs[counter])) { + counter++; + } + } + addMessageListener("waitForConsole", () => { + sendAsyncMessage("consoleDone", { + ok: counter >= msgs.length, + message: `monitorConsole | messages left expected at least ${msgs.length} got ${counter}`, + }); + Services.console.unregisterListener(listener); + }); + + Services.console.registerListener(listener); +} + +addMessageListener("consoleStart", messages => { + for (let msg of messages) { + // Message might be a RegExp object from a different compartment, but + // instanceof RegExp will fail. If we have an object, lets just make + // sure. + let message = msg.message; + if (typeof message == "object" && !(message instanceof RegExp)) { + msg.message = new RegExp(message); + } + } + monitorConsole(messages); +}); diff --git a/toolkit/components/extensions/test/mochitest/oauth.html b/toolkit/components/extensions/test/mochitest/oauth.html new file mode 100644 index 0000000000..8b9b1d65ec --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/oauth.html @@ -0,0 +1,26 @@ + + + + + + +
+
+ + diff --git a/toolkit/components/extensions/test/mochitest/redirect_auto.sjs b/toolkit/components/extensions/test/mochitest/redirect_auto.sjs new file mode 100644 index 0000000000..bf7af2556b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/redirect_auto.sjs @@ -0,0 +1,24 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ +"use strict"; +Cu.importGlobalProperties(["URLSearchParams", "URL"]); + +function handleRequest(request, response) { + let params = new URLSearchParams(request.queryString); + if (params.has("no_redirect")) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); + } else { + if (request.method == "POST") { + response.setStatusLine(request.httpVersion, 303, "Redirected"); + } else { + response.setStatusLine(request.httpVersion, 302, "Moved Temporarily"); + } + let url = new URL( + params.get("redirect_uri") || params.get("default_redirect") + ); + url.searchParams.set("access_token", "here ya go"); + response.setHeader("Location", url.href); + } +} diff --git a/toolkit/components/extensions/test/mochitest/redirection.sjs b/toolkit/components/extensions/test/mochitest/redirection.sjs new file mode 100644 index 0000000000..873a3d41ba --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/redirection.sjs @@ -0,0 +1,6 @@ +"use strict"; + +function handleRequest(request, response) { + response.setStatusLine(request.httpVersion, 302); + response.setHeader("Location", "./dummy_page.html"); +} diff --git a/toolkit/components/extensions/test/mochitest/return_headers.sjs b/toolkit/components/extensions/test/mochitest/return_headers.sjs new file mode 100644 index 0000000000..46beab8185 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/return_headers.sjs @@ -0,0 +1,19 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported handleRequest */ + +function handleRequest(request, response) { + response.setHeader("Content-Type", "text/plain", false); + + let headers = {}; + // Why on earth is this a nsISimpleEnumerator... + let enumerator = request.headers; + while (enumerator.hasMoreElements()) { + let header = enumerator.getNext().data; + headers[header.toLowerCase()] = request.getHeader(header); + } + + response.write(JSON.stringify(headers)); +} diff --git a/toolkit/components/extensions/test/mochitest/serviceWorker.js b/toolkit/components/extensions/test/mochitest/serviceWorker.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/toolkit/components/extensions/test/mochitest/slow_response.sjs b/toolkit/components/extensions/test/mochitest/slow_response.sjs new file mode 100644 index 0000000000..d39c4c0bf0 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/slow_response.sjs @@ -0,0 +1,60 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80 ft=javascript: */ +"use strict"; + +/* eslint-disable no-unused-vars */ + +let { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const DELAY = AppConstants.DEBUG ? 4000 : 800; + +let nsTimer = Components.Constructor( + "@mozilla.org/timer;1", + "nsITimer", + "initWithCallback" +); + +let timer; +function delay() { + return new Promise(resolve => { + timer = nsTimer(resolve, DELAY, Ci.nsITimer.TYPE_ONE_SHOT); + }); +} + +const PARTS = [ + ` + + + + + + `, + "Lorem ipsum dolor sit amet,
", + "consectetur adipiscing elit,
", + "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
", + "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
", + "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
", + "Excepteur sint occaecat cupidatat non proident,
", + "sunt in culpa qui officia deserunt mollit anim id est laborum.
", + ` + + `, +]; + +async function handleRequest(request, response) { + response.processAsync(); + + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Cache-Control", "no-cache", false); + + await delay(); + + for (let part of PARTS) { + response.write(`${part}\n`); + await delay(); + } + + response.finish(); +} diff --git a/toolkit/components/extensions/test/mochitest/test_check_startupcache.html b/toolkit/components/extensions/test/mochitest/test_check_startupcache.html new file mode 100644 index 0000000000..8cb529d18d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_check_startupcache.html @@ -0,0 +1,63 @@ + + + + Check StartupCache + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_data_uri.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_data_uri.html new file mode 100644 index 0000000000..42950c50ec --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_data_uri.html @@ -0,0 +1,104 @@ + + + + Test content script matching a data: URI + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_telemetry.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_telemetry.html new file mode 100644 index 0000000000..d2bb66d507 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_telemetry.html @@ -0,0 +1,112 @@ + + + + Test for telemetry for content script injection + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_unrecognizedprop_warning.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_unrecognizedprop_warning.html new file mode 100644 index 0000000000..40403dea2b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_unrecognizedprop_warning.html @@ -0,0 +1,80 @@ + + + + Test for content script unrecognized property on manifest + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_open.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_open.html new file mode 100644 index 0000000000..530937c1ac --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_open.html @@ -0,0 +1,114 @@ + + + + Test for permissions + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_saveAs.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_saveAs.html new file mode 100644 index 0000000000..4b5d90814c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_saveAs.html @@ -0,0 +1,259 @@ + + + + Test downloads.download() saveAs option + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_uniquify.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_uniquify.html new file mode 100644 index 0000000000..99a6c48500 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_uniquify.html @@ -0,0 +1,118 @@ + + + + Test downloads.download() uniquify option + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_permissions.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_permissions.html new file mode 100644 index 0000000000..65bf0a50d0 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_permissions.html @@ -0,0 +1,172 @@ + + + + Test for permissions + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_svg_context_fill.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_svg_context_fill.html new file mode 100644 index 0000000000..c15ae9adf7 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_svg_context_fill.html @@ -0,0 +1,204 @@ + + + + Test for permissions + + + + + + + +

Testing on:

+ + + + + + + + + + + + + + + +
webext imageallowed refdisallowed ref
+ + +
+
+
+
+ + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_trackingprotection.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_trackingprotection.html new file mode 100644 index 0000000000..438fb06706 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_trackingprotection.html @@ -0,0 +1,100 @@ + + + + Test for simple WebExtension + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_webnavigation_resolved_urls.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webnavigation_resolved_urls.html new file mode 100644 index 0000000000..7e876694a0 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webnavigation_resolved_urls.html @@ -0,0 +1,81 @@ + + + + Test for simple WebExtension + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_background_events.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_background_events.html new file mode 100644 index 0000000000..4caa4d2464 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_background_events.html @@ -0,0 +1,96 @@ + + + + Test for simple WebExtension + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_host_permissions.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_host_permissions.html new file mode 100644 index 0000000000..19c812f59f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_host_permissions.html @@ -0,0 +1,89 @@ + + + + Test webRequest checks host permissions + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_mozextension.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_mozextension.html new file mode 100644 index 0000000000..6a41b9cf08 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_mozextension.html @@ -0,0 +1,193 @@ + + + + Test moz-extension protocol use + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_native_messaging_paths.html b/toolkit/components/extensions/test/mochitest/test_chrome_native_messaging_paths.html new file mode 100644 index 0000000000..c29b6286d9 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_native_messaging_paths.html @@ -0,0 +1,58 @@ + + + + WebExtension test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_action.html b/toolkit/components/extensions/test/mochitest/test_ext_action.html new file mode 100644 index 0000000000..16826d06f8 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_action.html @@ -0,0 +1,50 @@ + + + + + Action with MV3 + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_activityLog.html b/toolkit/components/extensions/test/mochitest/test_ext_activityLog.html new file mode 100644 index 0000000000..c426913373 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_activityLog.html @@ -0,0 +1,390 @@ + + + + WebExtension activityLog test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js b/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js new file mode 100644 index 0000000000..95ac9af50d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js @@ -0,0 +1,248 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Tests whether not too many APIs are visible by default. +// This file is used by test_ext_all_apis.html in browser/ and mobile/android/, +// which may modify the following variables to add or remove expected APIs. +/* globals expectedContentApisTargetSpecific */ +/* globals expectedBackgroundApisTargetSpecific */ + +// Generates a list of expectations. +function generateExpectations(list) { + return list + .reduce((allApis, path) => { + return allApis.concat(`browser.${path}`, `chrome.${path}`); + }, []) + .sort(); +} + +let expectedCommonApis = [ + "extension.getURL", + "extension.inIncognitoContext", + "extension.lastError", + "i18n.detectLanguage", + "i18n.getAcceptLanguages", + "i18n.getMessage", + "i18n.getUILanguage", + "runtime.OnInstalledReason", + "runtime.OnRestartRequiredReason", + "runtime.PlatformArch", + "runtime.PlatformOs", + "runtime.RequestUpdateCheckStatus", + "runtime.getManifest", + "runtime.connect", + "runtime.getFrameId", + "runtime.getURL", + "runtime.id", + "runtime.lastError", + "runtime.onConnect", + "runtime.onMessage", + "runtime.sendMessage", + // browser.test is only available in xpcshell or when + // Cu.isInAutomation is true. + "test.assertDeepEq", + "test.assertEq", + "test.assertFalse", + "test.assertRejects", + "test.assertThrows", + "test.assertTrue", + "test.fail", + "test.log", + "test.notifyFail", + "test.notifyPass", + "test.onMessage", + "test.sendMessage", + "test.succeed", + "test.withHandlingUserInput", +]; + +let expectedContentApis = [ + ...expectedCommonApis, + ...expectedContentApisTargetSpecific, +]; + +let expectedBackgroundApis = [ + ...expectedCommonApis, + ...expectedBackgroundApisTargetSpecific, + "contentScripts.register", + "experiments.APIChildScope", + "experiments.APIEvent", + "experiments.APIParentScope", + "extension.ViewType", + "extension.getBackgroundPage", + "extension.getViews", + "extension.isAllowedFileSchemeAccess", + "extension.isAllowedIncognitoAccess", + // Note: extensionTypes is not visible in Chrome. + "extensionTypes.CSSOrigin", + "extensionTypes.ImageFormat", + "extensionTypes.RunAt", + "management.ExtensionDisabledReason", + "management.ExtensionInstallType", + "management.ExtensionType", + "management.getSelf", + "management.uninstallSelf", + "permissions.getAll", + "permissions.contains", + "permissions.request", + "permissions.remove", + "permissions.onAdded", + "permissions.onRemoved", + "runtime.getBackgroundPage", + "runtime.getBrowserInfo", + "runtime.getPlatformInfo", + "runtime.onConnectExternal", + "runtime.onInstalled", + "runtime.onMessageExternal", + "runtime.onPerformanceWarning", + "runtime.onStartup", + "runtime.onSuspend", + "runtime.onSuspendCanceled", + "runtime.onUpdateAvailable", + "runtime.openOptionsPage", + "runtime.reload", + "runtime.setUninstallURL", + "runtime.OnPerformanceWarningCategory", + "runtime.OnPerformanceWarningSeverity", + "theme.getCurrent", + "theme.onUpdated", + "types.LevelOfControl", + "types.SettingScope", +]; + +// APIs that are exposed to MV2 by default, but not to MV3. +const mv2onlyBackgroundApis = new Set([ + "extension.getURL", + "extension.lastError", + "contentScripts.register", + "tabs.executeScript", + "tabs.insertCSS", + "tabs.removeCSS", +]); +let expectedBackgroundApisMV3 = expectedBackgroundApis.filter( + path => !mv2onlyBackgroundApis.has(path) +); + +function sendAllApis() { + function isEvent(key, val) { + if (!/^on[A-Z]/.test(key)) { + return false; + } + let eventKeys = []; + for (let prop in val) { + eventKeys.push(prop); + } + eventKeys = eventKeys.sort().join(); + return eventKeys === "addListener,hasListener,removeListener"; + } + // Some items are removed from the namespaces in the lazy getters after the first get. This + // in one case, the events namespace, leaves a namespace that is empty. Make sure we don't + // consider those as a part of our testing. + function isEmptyObject(val) { + return val !== null && typeof val == "object" && !Object.keys(val).length; + } + function mayRecurse(key, val) { + if (Object.keys(val).filter(k => !/^[A-Z\-0-9_]+$/.test(k)).length === 0) { + // Don't recurse on constants and empty objects. + return false; + } + return !isEvent(key, val); + } + + let results = []; + function diveDeeper(path, obj) { + for (const [key, val] of Object.entries(obj)) { + if (typeof val == "object" && val !== null && mayRecurse(key, val)) { + diveDeeper(`${path}.${key}`, val); + } else if (val !== undefined && !isEmptyObject(val)) { + results.push(`${path}.${key}`); + } + } + } + diveDeeper("browser", browser); + diveDeeper("chrome", chrome); + browser.test.sendMessage("allApis", results.sort()); + browser.test.sendMessage("namespaces", browser === chrome); +} + +add_task(async function setup() { + // This test enumerates all APIs and may access a deprecated API. Just log a + // warning instead of throwing. + await ExtensionTestUtils.failOnSchemaWarnings(false); +}); + +add_task(async function test_enumerate_content_script_apis() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://mochi.test/*/file_sample.html"], + js: ["contentscript.js"], + run_at: "document_start", + }, + ], + }, + files: { + "contentscript.js": sendAllApis, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let win = window.open("file_sample.html"); + let actualApis = await extension.awaitMessage("allApis"); + win.close(); + let expectedApis = generateExpectations(expectedContentApis); + isDeeply(actualApis, expectedApis, "content script APIs"); + + let sameness = await extension.awaitMessage("namespaces"); + ok(sameness, "namespaces are same object"); + + await extension.unload(); +}); + +add_task(async function test_enumerate_background_script_apis() { + let extensionData = { + background: sendAllApis, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let actualApis = await extension.awaitMessage("allApis"); + let expectedApis = generateExpectations(expectedBackgroundApis); + isDeeply(actualApis, expectedApis, "background script APIs"); + + let sameness = await extension.awaitMessage("namespaces"); + ok(!sameness, "namespaces are different objects"); + + await extension.unload(); +}); + +add_task(async function test_enumerate_background_script_apis_mv3() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.manifestV3.enabled", true]], + }); + let extensionData = { + background: sendAllApis, + manifest: { + manifest_version: 3, + + // Features that expose APIs in MV2, but should not do anything with MV3. + browser_action: {}, + user_scripts: {}, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let actualApis = await extension.awaitMessage("allApis"); + let expectedApis = generateExpectations(expectedBackgroundApisMV3); + isDeeply(actualApis, expectedApis, "background script APIs in MV3"); + + let sameness = await extension.awaitMessage("namespaces"); + ok(sameness, "namespaces are same object"); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/components/extensions/test/mochitest/test_ext_async_clipboard.html b/toolkit/components/extensions/test/mochitest/test_ext_async_clipboard.html new file mode 100644 index 0000000000..4bd8339357 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_async_clipboard.html @@ -0,0 +1,401 @@ + + + + Async Clipboard permissions tests + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_background_canvas.html b/toolkit/components/extensions/test/mochitest/test_ext_background_canvas.html new file mode 100644 index 0000000000..e7745f08c5 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_background_canvas.html @@ -0,0 +1,42 @@ + + + + Test for background page canvas rendering + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_background_page.html b/toolkit/components/extensions/test/mochitest/test_ext_background_page.html new file mode 100644 index 0000000000..2f4fe3b96c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_background_page.html @@ -0,0 +1,84 @@ + + + + WebExtension test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_background_page_dpi.html b/toolkit/components/extensions/test/mochitest/test_ext_background_page_dpi.html new file mode 100644 index 0000000000..40772402b1 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_background_page_dpi.html @@ -0,0 +1,46 @@ + + +DPI of background page + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browserAction_getUserSettings.html b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_getUserSettings.html new file mode 100644 index 0000000000..6e3d9391bc --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_getUserSettings.html @@ -0,0 +1,50 @@ + + + + + action.getUserSettings Test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browserAction_onClicked.html b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_onClicked.html new file mode 100644 index 0000000000..48db7b0e92 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_onClicked.html @@ -0,0 +1,100 @@ + + + + + browserAction.onClicked test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup.html b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup.html new file mode 100644 index 0000000000..0d038da897 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup.html @@ -0,0 +1,183 @@ + + + + action.openPopup Test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_incognito_window.html b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_incognito_window.html new file mode 100644 index 0000000000..8036d97398 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_incognito_window.html @@ -0,0 +1,151 @@ + + + + action.openPopup Incognito Test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_windowId.html b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_windowId.html new file mode 100644 index 0000000000..c528028901 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_windowId.html @@ -0,0 +1,162 @@ + + + + action.openPopup Window ID Test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_without_pref.html b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_without_pref.html new file mode 100644 index 0000000000..aa7285d5f5 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browserAction_openPopup_without_pref.html @@ -0,0 +1,58 @@ + + + + action.openPopup Preference Test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browserSettings_overrideDocumentColors.html b/toolkit/components/extensions/test/mochitest/test_ext_browserSettings_overrideDocumentColors.html new file mode 100644 index 0000000000..6486216376 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browserSettings_overrideDocumentColors.html @@ -0,0 +1,175 @@ + + + + + browserSettings.overrideDocumentColors + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browsingData_indexedDB.html b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_indexedDB.html new file mode 100644 index 0000000000..f8ea41ddab --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_indexedDB.html @@ -0,0 +1,159 @@ + + + + + Test browsingData.remove indexedDB + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browsingData_localStorage.html b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_localStorage.html new file mode 100644 index 0000000000..2fd608f125 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_localStorage.html @@ -0,0 +1,323 @@ + + + + + Test browsingData.remove indexedDB + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browsingData_pluginData.html b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_pluginData.html new file mode 100644 index 0000000000..bf4bd8fe80 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_pluginData.html @@ -0,0 +1,69 @@ + + + + + Test browsingData.remove indexedDB + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browsingData_serviceWorkers.html b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_serviceWorkers.html new file mode 100644 index 0000000000..d8ebd8e225 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_serviceWorkers.html @@ -0,0 +1,141 @@ + + + + + Test browsingData.remove indexedDB + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browsingData_settings.html b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_settings.html new file mode 100644 index 0000000000..11c690e5bf --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_settings.html @@ -0,0 +1,65 @@ + + + + + Test browsingData.settings + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_canvas_resistFingerprinting.html b/toolkit/components/extensions/test/mochitest/test_ext_canvas_resistFingerprinting.html new file mode 100644 index 0000000000..f60f335dc5 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_canvas_resistFingerprinting.html @@ -0,0 +1,65 @@ + + + + WebExtension test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_clipboard.html b/toolkit/components/extensions/test/mochitest/test_ext_clipboard.html new file mode 100644 index 0000000000..77ac767391 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_clipboard.html @@ -0,0 +1,210 @@ + + + + Clipboard permissions tests + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_clipboard_image.html b/toolkit/components/extensions/test/mochitest/test_ext_clipboard_image.html new file mode 100644 index 0000000000..b5d5f6764a --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_clipboard_image.html @@ -0,0 +1,262 @@ + + + + Clipboard permissions tests + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html new file mode 100644 index 0000000000..1ca3cd619a --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html @@ -0,0 +1,335 @@ + + + + Test content script match_about_blank option + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_activeTab.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_activeTab.html new file mode 100644 index 0000000000..076c177dfa --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_activeTab.html @@ -0,0 +1,703 @@ + + + + Test for content script + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_cache.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_cache.html new file mode 100644 index 0000000000..5caab9129d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_cache.html @@ -0,0 +1,117 @@ + + + + Test for content script caching + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_canvas.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_canvas.html new file mode 100644 index 0000000000..8659d8c409 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_canvas.html @@ -0,0 +1,134 @@ + + + + Test content script access to canvas drawWindow() + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_devtools_metadata.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_devtools_metadata.html new file mode 100644 index 0000000000..6b67be6b5b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_devtools_metadata.html @@ -0,0 +1,77 @@ + + + + Test for Sandbox metadata on WebExtensions ContentScripts + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_fission_frame.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_fission_frame.html new file mode 100644 index 0000000000..6e03c3e9cd --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_fission_frame.html @@ -0,0 +1,109 @@ + + + Test content script in cross-origin frame + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_getFrameId.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_getFrameId.html new file mode 100644 index 0000000000..d679634030 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_getFrameId.html @@ -0,0 +1,189 @@ + + + Test content script runtime.getFrameId + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_incognito.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_incognito.html new file mode 100644 index 0000000000..de2a2571a9 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_incognito.html @@ -0,0 +1,101 @@ + + + + Test for content script private browsing ID + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_permission.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_permission.html new file mode 100644 index 0000000000..8ab4b1fb28 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_permission.html @@ -0,0 +1,59 @@ + + + + Test for content script + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_securecontext.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_securecontext.html new file mode 100644 index 0000000000..093c26898f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_securecontext.html @@ -0,0 +1,163 @@ + + + + Test content script accessing certain [SecureContext] interfaces in non-secure contexts + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies.html new file mode 100644 index 0000000000..cdec628975 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies.html @@ -0,0 +1,367 @@ + + + + WebExtension test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_containers.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_containers.html new file mode 100644 index 0000000000..d4bbd61177 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_containers.html @@ -0,0 +1,98 @@ + + + + WebExtension test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_expiry.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_expiry.html new file mode 100644 index 0000000000..fa118f5271 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_expiry.html @@ -0,0 +1,72 @@ + + + + WebExtension cookies test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_first_party.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_first_party.html new file mode 100644 index 0000000000..7e33f4731d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_first_party.html @@ -0,0 +1,316 @@ + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_incognito.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_incognito.html new file mode 100644 index 0000000000..b33ceecf06 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_incognito.html @@ -0,0 +1,107 @@ + + + + WebExtension test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_bad.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_bad.html new file mode 100644 index 0000000000..0bd2852075 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_bad.html @@ -0,0 +1,115 @@ + + + + WebExtension test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_good.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_good.html new file mode 100644 index 0000000000..bd76f2b9c0 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_good.html @@ -0,0 +1,89 @@ + + + + WebExtension test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_dnr_other_extensions.html b/toolkit/components/extensions/test/mochitest/test_ext_dnr_other_extensions.html new file mode 100644 index 0000000000..d3074b3dec --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_dnr_other_extensions.html @@ -0,0 +1,113 @@ + + + + + DNR and tabs.create from other extension + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_dnr_tabIds.html b/toolkit/components/extensions/test/mochitest/test_ext_dnr_tabIds.html new file mode 100644 index 0000000000..0278a8ccc8 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_dnr_tabIds.html @@ -0,0 +1,137 @@ + + + + + DNR with tabIds condition + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_dnr_upgradeScheme.html b/toolkit/components/extensions/test/mochitest/test_ext_dnr_upgradeScheme.html new file mode 100644 index 0000000000..43bc8a5a00 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_dnr_upgradeScheme.html @@ -0,0 +1,137 @@ + + + + + DNR with upgradeScheme action + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_downloads_download.html b/toolkit/components/extensions/test/mochitest/test_ext_downloads_download.html new file mode 100644 index 0000000000..23058c35ec --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_downloads_download.html @@ -0,0 +1,94 @@ + + + + + Downloads Test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_embeddedimg_iframe_frameAncestors.html b/toolkit/components/extensions/test/mochitest/test_ext_embeddedimg_iframe_frameAncestors.html new file mode 100644 index 0000000000..d6702da4d3 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_embeddedimg_iframe_frameAncestors.html @@ -0,0 +1,94 @@ + + + + Test checking webRequest.onBeforeRequest details object + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_exclude_include_globs.html b/toolkit/components/extensions/test/mochitest/test_ext_exclude_include_globs.html new file mode 100644 index 0000000000..f87b5620d6 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_exclude_include_globs.html @@ -0,0 +1,91 @@ + + + + Test for content script + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_extension_iframe_messaging.html b/toolkit/components/extensions/test/mochitest/test_ext_extension_iframe_messaging.html new file mode 100644 index 0000000000..403782ab7d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_extension_iframe_messaging.html @@ -0,0 +1,124 @@ + + + + Test moz-extension iframe messaging + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_external_messaging.html b/toolkit/components/extensions/test/mochitest/test_ext_external_messaging.html new file mode 100644 index 0000000000..639cacef28 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_external_messaging.html @@ -0,0 +1,110 @@ + + + + WebExtension external messaging + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_generate.html b/toolkit/components/extensions/test/mochitest/test_ext_generate.html new file mode 100644 index 0000000000..ba88d16ca3 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_generate.html @@ -0,0 +1,48 @@ + + + + Test for generating WebExtensions + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_geolocation.html b/toolkit/components/extensions/test/mochitest/test_ext_geolocation.html new file mode 100644 index 0000000000..9f326372bb --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_geolocation.html @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_identity.html b/toolkit/components/extensions/test/mochitest/test_ext_identity.html new file mode 100644 index 0000000000..7aa590ec22 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_identity.html @@ -0,0 +1,390 @@ + + + + Test for WebExtension Identity + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_idle.html b/toolkit/components/extensions/test/mochitest/test_ext_idle.html new file mode 100644 index 0000000000..381687ee38 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_idle.html @@ -0,0 +1,68 @@ + + + + WebExtension test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_inIncognitoContext_window.html b/toolkit/components/extensions/test/mochitest/test_ext_inIncognitoContext_window.html new file mode 100644 index 0000000000..5b36902581 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_inIncognitoContext_window.html @@ -0,0 +1,49 @@ + + + + WebExtension test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_listener_proxies.html b/toolkit/components/extensions/test/mochitest/test_ext_listener_proxies.html new file mode 100644 index 0000000000..cc161f735f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_listener_proxies.html @@ -0,0 +1,62 @@ + + + + Test for content script + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_new_tab_processType.html b/toolkit/components/extensions/test/mochitest/test_ext_new_tab_processType.html new file mode 100644 index 0000000000..2c5ae1c0ba --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_new_tab_processType.html @@ -0,0 +1,168 @@ + + + + Test for opening links in new tabs from extension frames + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_notifications.html b/toolkit/components/extensions/test/mochitest/test_ext_notifications.html new file mode 100644 index 0000000000..7a91320373 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_notifications.html @@ -0,0 +1,340 @@ + + + + Test for notifications + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_optional_permissions.html b/toolkit/components/extensions/test/mochitest/test_ext_optional_permissions.html new file mode 100644 index 0000000000..659a55f5c9 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_optional_permissions.html @@ -0,0 +1,98 @@ + + + + + optional permissions and preloaded processes + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_pageAction_onClicked.html b/toolkit/components/extensions/test/mochitest/test_ext_pageAction_onClicked.html new file mode 100644 index 0000000000..5fce66159d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_pageAction_onClicked.html @@ -0,0 +1,111 @@ + + + + + pageAction.onClicked test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_protocolHandlers.html b/toolkit/components/extensions/test/mochitest/test_ext_protocolHandlers.html new file mode 100644 index 0000000000..7032bfe6f1 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_protocolHandlers.html @@ -0,0 +1,612 @@ + + + + + Test for protocol handlers + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_redirect_jar.html b/toolkit/components/extensions/test/mochitest/test_ext_redirect_jar.html new file mode 100644 index 0000000000..18ff14a6de --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_redirect_jar.html @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_request_urlClassification.html b/toolkit/components/extensions/test/mochitest/test_ext_request_urlClassification.html new file mode 100644 index 0000000000..a139e94687 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_request_urlClassification.html @@ -0,0 +1,134 @@ + + + + Test for WebRequest urlClassification + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect.html new file mode 100644 index 0000000000..85f98d5034 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect.html @@ -0,0 +1,83 @@ + + + + WebExtension test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect2.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect2.html new file mode 100644 index 0000000000..13b9029c48 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect2.html @@ -0,0 +1,102 @@ + + + + WebExtension test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_iframe.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_iframe.html new file mode 100644 index 0000000000..9c64635063 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_iframe.html @@ -0,0 +1,136 @@ + + + + WebExtension test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_twoway.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_twoway.html new file mode 100644 index 0000000000..b671cba23d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_twoway.html @@ -0,0 +1,126 @@ + + + + WebExtension test + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_disconnect.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_disconnect.html new file mode 100644 index 0000000000..f18190bf8b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_disconnect.html @@ -0,0 +1,77 @@ + + + + WebExtension test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_script_filenames.html b/toolkit/components/extensions/test/mochitest/test_ext_script_filenames.html new file mode 100644 index 0000000000..de0993c33d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_script_filenames.html @@ -0,0 +1,62 @@ + + + + Script Filenames Test + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_scripting_contentScripts.html b/toolkit/components/extensions/test/mochitest/test_ext_scripting_contentScripts.html new file mode 100644 index 0000000000..9daff87416 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_scripting_contentScripts.html @@ -0,0 +1,1649 @@ + + + + + Tests scripting.*ContentScripts() + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript.html b/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript.html new file mode 100644 index 0000000000..a2d741606f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript.html @@ -0,0 +1,1479 @@ + + + + + Tests scripting.executeScript() + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_activeTab.html b/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_activeTab.html new file mode 100644 index 0000000000..5eb2193409 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_activeTab.html @@ -0,0 +1,144 @@ + + + + + Tests scripting.executeScript() and activeTab + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_injectImmediately.html b/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_injectImmediately.html new file mode 100644 index 0000000000..9d05925adc --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_scripting_executeScript_injectImmediately.html @@ -0,0 +1,215 @@ + + + + + Tests scripting.executeScript() and injectImmediately + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_scripting_insertCSS.html b/toolkit/components/extensions/test/mochitest/test_ext_scripting_insertCSS.html new file mode 100644 index 0000000000..3e2cef8721 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_scripting_insertCSS.html @@ -0,0 +1,395 @@ + + + + + Tests scripting.insertCSS() + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_scripting_permissions.html b/toolkit/components/extensions/test/mochitest/test_ext_scripting_permissions.html new file mode 100644 index 0000000000..e3e6552290 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_scripting_permissions.html @@ -0,0 +1,149 @@ + + + + + Tests scripting APIs and permissions + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_scripting_removeCSS.html b/toolkit/components/extensions/test/mochitest/test_ext_scripting_removeCSS.html new file mode 100644 index 0000000000..3036e49761 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_scripting_removeCSS.html @@ -0,0 +1,135 @@ + + + + + Tests scripting.removeCSS() + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_doublereply.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_doublereply.html new file mode 100644 index 0000000000..ffdbc90efb --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_doublereply.html @@ -0,0 +1,100 @@ + + + + WebExtension test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_frameId.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_frameId.html new file mode 100644 index 0000000000..6b42073031 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_frameId.html @@ -0,0 +1,45 @@ + + + Test sendMessage frameId + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_no_receiver.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_no_receiver.html new file mode 100644 index 0000000000..a18b003e48 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_no_receiver.html @@ -0,0 +1,115 @@ + + + + WebExtension test + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply.html new file mode 100644 index 0000000000..a7f6314efd --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply.html @@ -0,0 +1,78 @@ + + + + WebExtension test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply2.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply2.html new file mode 100644 index 0000000000..8cce833b49 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply2.html @@ -0,0 +1,202 @@ + + + + WebExtension test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_storage_cleanup.html b/toolkit/components/extensions/test/mochitest/test_ext_storage_cleanup.html new file mode 100644 index 0000000000..33029cf61e --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_storage_cleanup.html @@ -0,0 +1,277 @@ + + + + WebExtension test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_storage_manager_capabilities.html b/toolkit/components/extensions/test/mochitest/test_ext_storage_manager_capabilities.html new file mode 100644 index 0000000000..848a01c2c3 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_storage_manager_capabilities.html @@ -0,0 +1,129 @@ + + + + Test Storage API + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_storage_smoke_test.html b/toolkit/components/extensions/test/mochitest/test_ext_storage_smoke_test.html new file mode 100644 index 0000000000..e68caa7e55 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_storage_smoke_test.html @@ -0,0 +1,108 @@ + + + + WebExtension test + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_multiple.html b/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_multiple.html new file mode 100644 index 0000000000..d1bfbd824b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_multiple.html @@ -0,0 +1,91 @@ + + + + Test for multiple extensions trying to filterResponseData on the same request + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_processswitch.html b/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_processswitch.html new file mode 100644 index 0000000000..049178cad0 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_processswitch.html @@ -0,0 +1,76 @@ + + + + Test for using filterResponseData to intercept a cross-origin navigation that will involve a process switch with fission + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_subframes_privileges.html b/toolkit/components/extensions/test/mochitest/test_ext_subframes_privileges.html new file mode 100644 index 0000000000..fd034f0b65 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_subframes_privileges.html @@ -0,0 +1,340 @@ + + + + + WebExtension test + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_captureTab.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_captureTab.html new file mode 100644 index 0000000000..ab06a965ed --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_captureTab.html @@ -0,0 +1,324 @@ + + + + + Tests tabs.captureTab and tabs.captureVisibleTab + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_create_cookieStoreId.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_create_cookieStoreId.html new file mode 100644 index 0000000000..331faca016 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_create_cookieStoreId.html @@ -0,0 +1,210 @@ + + + + Test tabs.create(cookieStoreId) + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_detectLanguage.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_detectLanguage.html new file mode 100644 index 0000000000..ad41545429 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_detectLanguage.html @@ -0,0 +1,85 @@ + + + + Verify detectLangauge + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_executeScript_good.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_executeScript_good.html new file mode 100644 index 0000000000..9b0f41f789 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_executeScript_good.html @@ -0,0 +1,167 @@ + + + + Tabs executeScript Test + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_permissions.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_permissions.html new file mode 100644 index 0000000000..217139f12b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_permissions.html @@ -0,0 +1,752 @@ + + + + Tabs permissions test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_query_popup.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_query_popup.html new file mode 100644 index 0000000000..80b6def0ab --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_query_popup.html @@ -0,0 +1,102 @@ + + + + Tabs create Test + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html new file mode 100644 index 0000000000..4b230c258c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html @@ -0,0 +1,152 @@ + + + + Test tabs.sendMessage + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_test.html b/toolkit/components/extensions/test/mochitest/test_ext_test.html new file mode 100644 index 0000000000..bf68786465 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_test.html @@ -0,0 +1,341 @@ + + + + Testing test + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage.html b/toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage.html new file mode 100644 index 0000000000..d4aeb04bb5 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage.html @@ -0,0 +1,139 @@ + + + + Test for simple WebExtension + + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_incognito.html b/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_incognito.html new file mode 100644 index 0000000000..d1c41d2030 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_incognito.html @@ -0,0 +1,170 @@ + + + + Test the web_accessible_resources incognito + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html b/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html new file mode 100644 index 0000000000..c13e40e265 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html @@ -0,0 +1,567 @@ + + + + Test the web_accessible_resources manifest directive + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webnavigation.html b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation.html new file mode 100644 index 0000000000..12c90f8350 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation.html @@ -0,0 +1,610 @@ + + + + Test for simple WebExtension + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_filters.html b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_filters.html new file mode 100644 index 0000000000..19cb6539d7 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_filters.html @@ -0,0 +1,313 @@ + + + + Test for simple WebExtension + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_incognito.html b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_incognito.html new file mode 100644 index 0000000000..45147365ee --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_incognito.html @@ -0,0 +1,105 @@ + + + + Test for simple WebExtension + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_and_proxy_filter.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_and_proxy_filter.html new file mode 100644 index 0000000000..b28cbb7635 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_and_proxy_filter.html @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html new file mode 100644 index 0000000000..f260f040a1 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html @@ -0,0 +1,181 @@ + + + + + + + + + + + + + +
Authorization Test
+ + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_background_events.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_background_events.html new file mode 100644 index 0000000000..86cec62fb4 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_background_events.html @@ -0,0 +1,120 @@ + + + + Test for simple WebExtension + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html new file mode 100644 index 0000000000..9d57d55681 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html @@ -0,0 +1,445 @@ + + + + + + + + + + + + + +
Sample text
+ + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_errors.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_errors.html new file mode 100644 index 0000000000..cbfc5c17e7 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_errors.html @@ -0,0 +1,59 @@ + + + + Test for WebRequest errors + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_filter.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_filter.html new file mode 100644 index 0000000000..5ccbf761ec --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_filter.html @@ -0,0 +1,226 @@ + + + + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_frameId.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_frameId.html new file mode 100644 index 0000000000..76a13be1af --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_frameId.html @@ -0,0 +1,213 @@ + + + + + + + + + + + + + +
Sample text
+ + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_getSecurityInfo.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_getSecurityInfo.html new file mode 100644 index 0000000000..5628109483 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_getSecurityInfo.html @@ -0,0 +1,98 @@ + + + + + browser.webRequest.getSecurityInfo() + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_hsts.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_hsts.html new file mode 100644 index 0000000000..e66b5c471a --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_hsts.html @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_bypass_cors.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_bypass_cors.html new file mode 100644 index 0000000000..87dbbd6598 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_bypass_cors.html @@ -0,0 +1,75 @@ + + + + Bug 1450965: Skip Cors Check for Early WebExtention Redirects + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_data_uri.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_data_uri.html new file mode 100644 index 0000000000..5d58549c46 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_data_uri.html @@ -0,0 +1,83 @@ + + + + Bug 1434357: Allow Web Request API to redirect to data: URI + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upgrade.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upgrade.html new file mode 100644 index 0000000000..f086d29d02 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upgrade.html @@ -0,0 +1,139 @@ + + + + Test for simple WebExtension + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upload.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upload.html new file mode 100644 index 0000000000..30ecb0aa78 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upload.html @@ -0,0 +1,265 @@ + + + + + + + + + + + + +
+ + + + +
+ +
+ + + +
+ + +
+ + +
+ + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_worker.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_worker.html new file mode 100644 index 0000000000..9fc3e00f01 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_worker.html @@ -0,0 +1,192 @@ + + + + + + + + + + + + + +
Sample text
+ + + diff --git a/toolkit/components/extensions/test/mochitest/test_ext_window_postMessage.html b/toolkit/components/extensions/test/mochitest/test_ext_window_postMessage.html new file mode 100644 index 0000000000..53b19d0ead --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_window_postMessage.html @@ -0,0 +1,104 @@ + + + + Test for content script + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_startup_canary.html b/toolkit/components/extensions/test/mochitest/test_startup_canary.html new file mode 100644 index 0000000000..1f705940c2 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_startup_canary.html @@ -0,0 +1,76 @@ + + + + Check StartupCache + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_verify_non_remote_mode.html b/toolkit/components/extensions/test/mochitest/test_verify_non_remote_mode.html new file mode 100644 index 0000000000..3713243c1b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_verify_non_remote_mode.html @@ -0,0 +1,32 @@ + + + + Verify non-remote mode + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_verify_remote_mode.html b/toolkit/components/extensions/test/mochitest/test_verify_remote_mode.html new file mode 100644 index 0000000000..2be0e19179 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_verify_remote_mode.html @@ -0,0 +1,22 @@ + + + + Verify remote mode + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/test_verify_sw_mode.html b/toolkit/components/extensions/test/mochitest/test_verify_sw_mode.html new file mode 100644 index 0000000000..5aea44b62b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_verify_sw_mode.html @@ -0,0 +1,24 @@ + + + + Verify WebExtension background service worker mode + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/mochitest/webrequest_chromeworker.js b/toolkit/components/extensions/test/mochitest/webrequest_chromeworker.js new file mode 100644 index 0000000000..14d3ad2bab --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/webrequest_chromeworker.js @@ -0,0 +1,9 @@ +"use strict"; + +/* eslint-env worker */ + +onmessage = function (event) { + fetch("https://example.com/example.txt").then(() => { + postMessage("Done!"); + }); +}; diff --git a/toolkit/components/extensions/test/mochitest/webrequest_test.sys.mjs b/toolkit/components/extensions/test/mochitest/webrequest_test.sys.mjs new file mode 100644 index 0000000000..33554f3023 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/webrequest_test.sys.mjs @@ -0,0 +1,16 @@ +export var webrequest_test = { + testFetch(url) { + return fetch(url); + }, + + testXHR(url) { + return new Promise(resolve => { + let xhr = new XMLHttpRequest(); + xhr.open("HEAD", url); + xhr.onload = () => { + resolve(); + }; + xhr.send(); + }); + }, +}; diff --git a/toolkit/components/extensions/test/mochitest/webrequest_worker.js b/toolkit/components/extensions/test/mochitest/webrequest_worker.js new file mode 100644 index 0000000000..dcffd08578 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/webrequest_worker.js @@ -0,0 +1,3 @@ +"use strict"; + +fetch("https://example.com/example.txt"); diff --git a/toolkit/components/extensions/test/xpcshell/.eslintrc.js b/toolkit/components/extensions/test/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..60d784b53c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/.eslintrc.js @@ -0,0 +1,13 @@ +"use strict"; + +module.exports = { + env: { + // The tests in this folder are testing based on WebExtensions, so lets + // just define the webextensions environment here. + webextensions: true, + // Many parts of WebExtensions test definitions (e.g. content scripts) also + // interact with the browser environment, so define that here as we don't + // have an easy way to handle per-function/scope usage yet. + browser: true, + }, +}; diff --git a/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherChild.sys.mjs b/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherChild.sys.mjs new file mode 100644 index 0000000000..907631dec1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherChild.sys.mjs @@ -0,0 +1,62 @@ +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "wdm", + "@mozilla.org/dom/workers/workerdebuggermanager;1", + "nsIWorkerDebuggerManager" +); + +export class TestWorkerWatcherChild extends JSProcessActorChild { + async receiveMessage(msg) { + switch (msg.name) { + case "Test:StartWatchingWorkers": + this.startWatchingWorkers(); + break; + case "Test:StopWatchingWorkers": + this.stopWatchingWorkers(); + break; + default: + // Ensure the test case will fail if this JSProcessActorChild does receive + // unexpected messages. + return Promise.reject( + new Error(`Unexpected message received: ${msg.name}`) + ); + } + } + + startWatchingWorkers() { + if (!this._workerDebuggerListener) { + const actor = this; + this._workerDebuggerListener = { + onRegister(dbg) { + actor.sendAsyncMessage("Test:WorkerSpawned", { + workerType: dbg.type, + workerUrl: dbg.url, + }); + }, + onUnregister(dbg) { + actor.sendAsyncMessage("Test:WorkerTerminated", { + workerType: dbg.type, + workerUrl: dbg.url, + }); + }, + }; + + lazy.wdm.addListener(this._workerDebuggerListener); + } + } + + stopWatchingWorkers() { + if (this._workerDebuggerListener) { + lazy.wdm.removeListener(this._workerDebuggerListener); + this._workerDebuggerListener = null; + } + } + + willDestroy() { + this.stopWatchingWorkers(); + } +} diff --git a/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherParent.sys.mjs b/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherParent.sys.mjs new file mode 100644 index 0000000000..a9d919f1ed --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherParent.sys.mjs @@ -0,0 +1,20 @@ +export class TestWorkerWatcherParent extends JSProcessActorParent { + constructor() { + super(); + // This is set by the test helper that does use these process actors. + this.eventEmitter = null; + } + + receiveMessage(msg) { + switch (msg.name) { + case "Test:WorkerSpawned": + this.eventEmitter?.emit("worker-spawned", msg.data); + break; + case "Test:WorkerTerminated": + this.eventEmitter?.emit("worker-terminated", msg.data); + break; + default: + throw new Error(`Unexpected message received: ${msg.name}`); + } + } +} diff --git a/toolkit/components/extensions/test/xpcshell/data/dummy_page.html b/toolkit/components/extensions/test/xpcshell/data/dummy_page.html new file mode 100644 index 0000000000..c1c9a4e043 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/dummy_page.html @@ -0,0 +1,7 @@ + + + + +

Page

+ + diff --git a/toolkit/components/extensions/test/xpcshell/data/empty_file_download.txt b/toolkit/components/extensions/test/xpcshell/data/empty_file_download.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/toolkit/components/extensions/test/xpcshell/data/file download.txt b/toolkit/components/extensions/test/xpcshell/data/file download.txt new file mode 100644 index 0000000000..6293c7af79 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file download.txt @@ -0,0 +1 @@ +This is a sample file used in download tests. diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_page2.html b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_page2.html new file mode 100644 index 0000000000..b2cf48f9e1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_page2.html @@ -0,0 +1,25 @@ + + + + + + + + + + + +
Sample text
+ + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.html b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.html new file mode 100644 index 0000000000..f6b5142c4d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.js b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.js new file mode 100644 index 0000000000..2981108b64 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.js @@ -0,0 +1,2 @@ +"use strict"; +window.testScript = "original"; diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.html b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.html new file mode 100644 index 0000000000..0979593f7b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.js b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.js new file mode 100644 index 0000000000..06fd42aa40 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.js @@ -0,0 +1,2 @@ +"use strict"; +window.testScript = "redirected"; diff --git a/toolkit/components/extensions/test/xpcshell/data/file_content_script_errors.html b/toolkit/components/extensions/test/xpcshell/data/file_content_script_errors.html new file mode 100644 index 0000000000..da1d1c32bc --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_content_script_errors.html @@ -0,0 +1,7 @@ + + + + + + Content script errors + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_csp.html b/toolkit/components/extensions/test/xpcshell/data/file_csp.html new file mode 100644 index 0000000000..9f5cf92f5a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_csp.html @@ -0,0 +1,14 @@ + + + + + + + + +
Sample text
+ + + + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_csp.html^headers^ b/toolkit/components/extensions/test/xpcshell/data/file_csp.html^headers^ new file mode 100644 index 0000000000..4c6fa3c26a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_csp.html^headers^ @@ -0,0 +1 @@ +Content-Security-Policy: default-src 'self' diff --git a/toolkit/components/extensions/test/xpcshell/data/file_do_load_script_subresource.html b/toolkit/components/extensions/test/xpcshell/data/file_do_load_script_subresource.html new file mode 100644 index 0000000000..c74dec5f5a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_do_load_script_subresource.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_document_open.html b/toolkit/components/extensions/test/xpcshell/data/file_document_open.html new file mode 100644 index 0000000000..dae5e90667 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_document_open.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_document_write.html b/toolkit/components/extensions/test/xpcshell/data/file_document_write.html new file mode 100644 index 0000000000..f8369ae574 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_document_write.html @@ -0,0 +1,36 @@ + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_download.html b/toolkit/components/extensions/test/xpcshell/data/file_download.html new file mode 100644 index 0000000000..d970c63259 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_download.html @@ -0,0 +1,12 @@ + + + + + + + + +
Download HTML File
+ + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_download.txt b/toolkit/components/extensions/test/xpcshell/data/file_download.txt new file mode 100644 index 0000000000..6293c7af79 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_download.txt @@ -0,0 +1 @@ +This is a sample file used in download tests. diff --git a/toolkit/components/extensions/test/xpcshell/data/file_iframe.html b/toolkit/components/extensions/test/xpcshell/data/file_iframe.html new file mode 100644 index 0000000000..0cd68be586 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_iframe.html @@ -0,0 +1,9 @@ + + + + + Iframe document + + + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_image_bad.png b/toolkit/components/extensions/test/xpcshell/data/file_image_bad.png new file mode 100644 index 0000000000..4c3be50847 Binary files /dev/null and b/toolkit/components/extensions/test/xpcshell/data/file_image_bad.png differ diff --git a/toolkit/components/extensions/test/xpcshell/data/file_image_good.png b/toolkit/components/extensions/test/xpcshell/data/file_image_good.png new file mode 100644 index 0000000000..769c636340 Binary files /dev/null and b/toolkit/components/extensions/test/xpcshell/data/file_image_good.png differ diff --git a/toolkit/components/extensions/test/xpcshell/data/file_image_redirect.png b/toolkit/components/extensions/test/xpcshell/data/file_image_redirect.png new file mode 100644 index 0000000000..4c3be50847 Binary files /dev/null and b/toolkit/components/extensions/test/xpcshell/data/file_image_redirect.png differ diff --git a/toolkit/components/extensions/test/xpcshell/data/file_page_xhr.html b/toolkit/components/extensions/test/xpcshell/data/file_page_xhr.html new file mode 100644 index 0000000000..387b5285f5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_page_xhr.html @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_permission_xhr.html b/toolkit/components/extensions/test/xpcshell/data/file_permission_xhr.html new file mode 100644 index 0000000000..6f1bb4648b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_permission_xhr.html @@ -0,0 +1,61 @@ + + + + + + + + + + + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_privilege_escalation.html b/toolkit/components/extensions/test/xpcshell/data/file_privilege_escalation.html new file mode 100644 index 0000000000..258f7058d9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_privilege_escalation.html @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_sample.html b/toolkit/components/extensions/test/xpcshell/data/file_sample.html new file mode 100644 index 0000000000..a20e49a1f0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_sample.html @@ -0,0 +1,12 @@ + + + + + + + + +
Sample text
+ + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_sample_registered_styles.html b/toolkit/components/extensions/test/xpcshell/data/file_sample_registered_styles.html new file mode 100644 index 0000000000..9f5c5d5a6a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_sample_registered_styles.html @@ -0,0 +1,13 @@ + + + + + + + + +
Registered Extension URL style
+
Registered Extension Text style
+ + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script.html b/toolkit/components/extensions/test/xpcshell/data/file_script.html new file mode 100644 index 0000000000..8d192b7d8e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_script.html @@ -0,0 +1,14 @@ + + + + + + + + + + +
Sample text
+ + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script_bad.js b/toolkit/components/extensions/test/xpcshell/data/file_script_bad.js new file mode 100644 index 0000000000..ff4572865b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_script_bad.js @@ -0,0 +1,12 @@ +"use strict"; + +window.failure = true; +window.addEventListener( + "load", + () => { + let el = document.createElement("div"); + el.setAttribute("id", "bad"); + document.body.appendChild(el); + }, + { once: true } +); diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script_good.js b/toolkit/components/extensions/test/xpcshell/data/file_script_good.js new file mode 100644 index 0000000000..bf47fb36d2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_script_good.js @@ -0,0 +1,12 @@ +"use strict"; + +window.success = window.success ? window.success + 1 : 1; +window.addEventListener( + "load", + () => { + let el = document.createElement("div"); + el.setAttribute("id", "good"); + document.body.appendChild(el); + }, + { once: true } +); diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script_redirect.js b/toolkit/components/extensions/test/xpcshell/data/file_script_redirect.js new file mode 100644 index 0000000000..c425122c71 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_script_redirect.js @@ -0,0 +1,3 @@ +"use strict"; + +window.failure = true; diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script_xhr.js b/toolkit/components/extensions/test/xpcshell/data/file_script_xhr.js new file mode 100644 index 0000000000..24a26cb8d1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_script_xhr.js @@ -0,0 +1,9 @@ +"use strict"; + +var request = new XMLHttpRequest(); +request.open( + "get", + "http://example.com/browser/toolkit/modules/tests/browser/xhr_resource", + false +); +request.send(); diff --git a/toolkit/components/extensions/test/xpcshell/data/file_shadowdom.html b/toolkit/components/extensions/test/xpcshell/data/file_shadowdom.html new file mode 100644 index 0000000000..c4e7db14e7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_shadowdom.html @@ -0,0 +1,13 @@ + + + + + + +
host
+ + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_style_bad.css b/toolkit/components/extensions/test/xpcshell/data/file_style_bad.css new file mode 100644 index 0000000000..8dbc8dc7a4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_style_bad.css @@ -0,0 +1,3 @@ +#test { + color: green !important; +} diff --git a/toolkit/components/extensions/test/xpcshell/data/file_style_good.css b/toolkit/components/extensions/test/xpcshell/data/file_style_good.css new file mode 100644 index 0000000000..46f9774b5f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_style_good.css @@ -0,0 +1,3 @@ +#test { + color: red; +} diff --git a/toolkit/components/extensions/test/xpcshell/data/file_style_redirect.css b/toolkit/components/extensions/test/xpcshell/data/file_style_redirect.css new file mode 100644 index 0000000000..8dbc8dc7a4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_style_redirect.css @@ -0,0 +1,3 @@ +#test { + color: green !important; +} diff --git a/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.css b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.css new file mode 100644 index 0000000000..6a9140d97e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.css @@ -0,0 +1 @@ +:root { color: green; } diff --git a/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.html b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.html new file mode 100644 index 0000000000..6d6d187a27 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.html @@ -0,0 +1,3 @@ + + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache_2.html b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache_2.html new file mode 100644 index 0000000000..07a4324c44 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache_2.html @@ -0,0 +1,19 @@ + + + + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_toplevel.html b/toolkit/components/extensions/test/xpcshell/data/file_toplevel.html new file mode 100644 index 0000000000..d93813d0f5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_toplevel.html @@ -0,0 +1,12 @@ + + + + + Top-level frame document + + + + + + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_with_iframe.html b/toolkit/components/extensions/test/xpcshell/data/file_with_iframe.html new file mode 100644 index 0000000000..705350d55c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_with_iframe.html @@ -0,0 +1,11 @@ + + + + + file with iframe + + +
+ + + diff --git a/toolkit/components/extensions/test/xpcshell/data/file_with_xorigin_frame.html b/toolkit/components/extensions/test/xpcshell/data/file_with_xorigin_frame.html new file mode 100644 index 0000000000..199c2ce4d4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_with_xorigin_frame.html @@ -0,0 +1,10 @@ + + + + + Document with example.org frame + + + + + diff --git a/toolkit/components/extensions/test/xpcshell/data/lorem.html.gz b/toolkit/components/extensions/test/xpcshell/data/lorem.html.gz new file mode 100644 index 0000000000..9eb8d73d50 Binary files /dev/null and b/toolkit/components/extensions/test/xpcshell/data/lorem.html.gz differ diff --git a/toolkit/components/extensions/test/xpcshell/data/pixel_green.gif b/toolkit/components/extensions/test/xpcshell/data/pixel_green.gif new file mode 100644 index 0000000000..baf8166dae Binary files /dev/null and b/toolkit/components/extensions/test/xpcshell/data/pixel_green.gif differ diff --git a/toolkit/components/extensions/test/xpcshell/data/pixel_red.gif b/toolkit/components/extensions/test/xpcshell/data/pixel_red.gif new file mode 100644 index 0000000000..48f97f74bd Binary files /dev/null and b/toolkit/components/extensions/test/xpcshell/data/pixel_red.gif differ diff --git a/toolkit/components/extensions/test/xpcshell/head.js b/toolkit/components/extensions/test/xpcshell/head.js new file mode 100644 index 0000000000..6935e3f0da --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head.js @@ -0,0 +1,530 @@ +"use strict"; +/* exported createHttpServer, cleanupDir, clearCache, optionalPermissionsPromptHandler, promiseConsoleOutput, + promiseQuotaManagerServiceReset, promiseQuotaManagerServiceClear, + runWithPrefs, testEnv, withHandlingUserInput, resetHandlingUserInput, + assertPersistentListeners, promiseExtensionEvent, assertHasPersistedScriptsCachedFlag, + assertIsPersistedScriptsCachedFlag + setup_crash_reporter_override_and_cleaner crashFrame crashExtensionBackground +*/ + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +var { + clearInterval, + clearTimeout, + setInterval, + setIntervalWithTarget, + setTimeout, + setTimeoutWithTarget, +} = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs"); +var { AddonTestUtils, MockAsyncShutdown } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + ContentTask: "resource://testing-common/ContentTask.sys.mjs", + Extension: "resource://gre/modules/Extension.sys.mjs", + ExtensionData: "resource://gre/modules/Extension.sys.mjs", + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", + ExtensionTestUtils: + "resource://testing-common/ExtensionXPCShellUtils.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + Management: "resource://gre/modules/Extension.sys.mjs", + MessageChannel: "resource://testing-common/MessageChannel.sys.mjs", + MockRegistrar: "resource://testing-common/MockRegistrar.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + PromiseTestUtils: "resource://testing-common/PromiseTestUtils.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Message manager disconnected/ +); + +// Persistent Listener test functionality +const { assertPersistentListeners } = ExtensionTestUtils.testAssertions; + +// https_first automatically upgrades http to https, but the tests are not +// designed to expect that. And it is not easy to change that because +// nsHttpServer does not support https (bug 1742061). So disable https_first. +Services.prefs.setBoolPref("dom.security.https_first", false); + +// These values may be changed in later head files and tested in check_remote +// below. +Services.prefs.setBoolPref("extensions.webextensions.remote", false); +const testEnv = { + expectRemote: false, +}; + +add_setup(function check_remote() { + Assert.equal( + WebExtensionPolicy.useRemoteWebExtensions, + testEnv.expectRemote, + "useRemoteWebExtensions matches" + ); + Assert.equal( + WebExtensionPolicy.isExtensionProcess, + !testEnv.expectRemote, + "testing from extension process" + ); +}); + +ExtensionTestUtils.init(this); + +var createHttpServer = (...args) => { + AddonTestUtils.maybeInit(this); + return AddonTestUtils.createHttpServer(...args); +}; + +// Some tests load non-moz-extension:-URLs in their extension document. When +// extensions run in-process (extensions.webextensions.remote set to false), +// that fails. +// For details, see: https://bugzilla.mozilla.org/show_bug.cgi?id=1724099 +// To avoid skip-if on the whole file, use this: +// +// add_task(async function test_description_here() { +// // Comment explaining why. +// allow_unsafe_parent_loads_when_extensions_not_remote(); +// ... +// revert_allow_unsafe_parent_loads_when_extensions_not_remote(); +// }); +var private_upl_cleanup_handlers = []; +function allow_unsafe_parent_loads_when_extensions_not_remote() { + if (WebExtensionPolicy.useRemoteWebExtensions) { + // We should only allow remote iframes in the main process. + return; + } + if (!Cu.isInAutomation) { + // isInAutomation is false by default in xpcshell (bug 1598804). Flip pref. + Services.prefs.setBoolPref( + "security.turn_off_all_security_so_that_viruses_can_take_over_this_computer", + true + ); + private_upl_cleanup_handlers.push(() => { + Services.prefs.setBoolPref( + "security.turn_off_all_security_so_that_viruses_can_take_over_this_computer", + false + ); + }); + // Sanity check: Fail immediately if setting the above pref does somehow + // not flip the isInAutomation flag. + if (!Cu.isInAutomation) { + // This condition is unexpected, because it is enforced at: + // https://searchfox.org/mozilla-central/rev/ea65de7c/js/xpconnect/src/xpcpublic.h#753-759 + throw new Error("Failed to set isInAutomation to true"); + } + } + // Note: The following pref requires the isInAutomation flag to be set. + // When unset, the pref is ignored, and tests would encounter bug 1724099. + if (!Services.prefs.getBoolPref("security.allow_unsafe_parent_loads")) { + info("Setting pref security.allow_unsafe_parent_loads to true"); + Services.prefs.setBoolPref("security.allow_unsafe_parent_loads", true); + private_upl_cleanup_handlers.push(() => { + info("Reverting pref security.allow_unsafe_parent_loads to false"); + Services.prefs.setBoolPref("security.allow_unsafe_parent_loads", false); + }); + } + + registerCleanupFunction( + // eslint-disable-next-line no-use-before-define + revert_allow_unsafe_parent_loads_when_extensions_not_remote + ); +} + +function revert_allow_unsafe_parent_loads_when_extensions_not_remote() { + for (let revert of private_upl_cleanup_handlers.splice(0)) { + revert(); + } +} + +/** + * Clears the HTTP and content image caches. + */ +function clearCache() { + Services.cache2.clear(); + + let imageCache = Cc["@mozilla.org/image/tools;1"] + .getService(Ci.imgITools) + .getImgCacheForDocument(null); + imageCache.clearCache(false); +} + +var promiseConsoleOutput = async function (task) { + const DONE = `=== console listener ${Math.random()} done ===`; + + let listener; + let messages = []; + let awaitListener = new Promise(resolve => { + listener = msg => { + if (msg == DONE) { + resolve(); + } else { + void (msg instanceof Ci.nsIConsoleMessage); + void (msg instanceof Ci.nsIScriptError); + messages.push(msg); + } + }; + }); + + Services.console.registerListener(listener); + try { + let result = await task(); + + Services.console.logStringMessage(DONE); + await awaitListener; + + return { messages, result }; + } finally { + Services.console.unregisterListener(listener); + } +}; + +// Attempt to remove a directory. If the Windows OS is still using the +// file sometimes remove() will fail. So try repeatedly until we can +// remove it or we give up. +function cleanupDir(dir) { + let count = 0; + return new Promise((resolve, reject) => { + function tryToRemoveDir() { + count += 1; + try { + dir.remove(true); + } catch (e) { + // ignore + } + if (!dir.exists()) { + return resolve(); + } + if (count >= 25) { + return reject(`Failed to cleanup directory: ${dir}`); + } + setTimeout(tryToRemoveDir, 100); + } + tryToRemoveDir(); + }); +} + +// Run a test with the specified preferences and then restores their initial values +// right after the test function run (whether it passes or fails). +async function runWithPrefs(prefsToSet, testFn) { + const setPrefs = prefs => { + for (let [pref, value] of prefs) { + if (value === undefined) { + // Clear any pref that didn't have a user value. + info(`Clearing pref "${pref}"`); + Services.prefs.clearUserPref(pref); + continue; + } + + info(`Setting pref "${pref}": ${value}`); + switch (typeof value) { + case "boolean": + Services.prefs.setBoolPref(pref, value); + break; + case "number": + Services.prefs.setIntPref(pref, value); + break; + case "string": + Services.prefs.setStringPref(pref, value); + break; + default: + throw new Error("runWithPrefs doesn't support this pref type yet"); + } + } + }; + + const getPrefs = prefs => { + return prefs.map(([pref, value]) => { + info(`Getting initial pref value for "${pref}"`); + if (!Services.prefs.prefHasUserValue(pref)) { + // Check if the pref doesn't have a user value. + return [pref, undefined]; + } + switch (typeof value) { + case "boolean": + return [pref, Services.prefs.getBoolPref(pref)]; + case "number": + return [pref, Services.prefs.getIntPref(pref)]; + case "string": + return [pref, Services.prefs.getStringPref(pref)]; + default: + throw new Error("runWithPrefs doesn't support this pref type yet"); + } + }); + }; + + let initialPrefsValues = []; + + try { + initialPrefsValues = getPrefs(prefsToSet); + + setPrefs(prefsToSet); + + await testFn(); + } finally { + info("Restoring initial preferences values on exit"); + setPrefs(initialPrefsValues); + } +} + +// "Handling User Input" test helpers. + +let extensionHandlers = new WeakSet(); + +function handlingUserInputFrameScript() { + /* globals content */ + // eslint-disable-next-line no-shadow + const { MessageChannel } = ChromeUtils.importESModule( + "resource://testing-common/MessageChannel.sys.mjs" + ); + + let handle; + MessageChannel.addListener(this, "ExtensionTest:HandleUserInput", { + receiveMessage({ name, data }) { + if (data) { + handle = content.windowUtils.setHandlingUserInput(true); + } else if (handle) { + handle.destruct(); + handle = null; + } + }, + }); +} + +// If you use withHandlingUserInput then restart the addon manager, +// you need to reset this before using withHandlingUserInput again. +function resetHandlingUserInput() { + extensionHandlers = new WeakSet(); +} + +async function withHandlingUserInput(extension, fn) { + let { messageManager } = extension.extension.groupFrameLoader; + + if (!extensionHandlers.has(extension)) { + messageManager.loadFrameScript( + `data:,(${encodeURI(handlingUserInputFrameScript)}).call(this)`, + false, + true + ); + extensionHandlers.add(extension); + } + + await MessageChannel.sendMessage( + messageManager, + "ExtensionTest:HandleUserInput", + true + ); + await fn(); + await MessageChannel.sendMessage( + messageManager, + "ExtensionTest:HandleUserInput", + false + ); +} + +// QuotaManagerService test helpers. + +function promiseQuotaManagerServiceReset() { + info("Calling QuotaManagerService.reset to enforce new test storage limits"); + return new Promise(resolve => { + Services.qms.reset().callback = resolve; + }); +} + +function promiseQuotaManagerServiceClear() { + info( + "Calling QuotaManagerService.clear to empty the test data and refresh test storage limits" + ); + return new Promise(resolve => { + Services.qms.clear().callback = resolve; + }); +} + +// Optional Permission prompt handling +const optionalPermissionsPromptHandler = { + sawPrompt: false, + acceptPrompt: false, + + init() { + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + true + ); + Services.obs.addObserver(this, "webextension-optional-permission-prompt"); + registerCleanupFunction(() => { + Services.obs.removeObserver( + this, + "webextension-optional-permission-prompt" + ); + Services.prefs.clearUserPref( + "extensions.webextOptionalPermissionPrompts" + ); + }); + }, + + observe(subject, topic, data) { + if (topic == "webextension-optional-permission-prompt") { + this.sawPrompt = true; + let { resolve } = subject.wrappedJSObject; + resolve(this.acceptPrompt); + } + }, +}; + +function promiseExtensionEvent(wrapper, event) { + return new Promise(resolve => { + wrapper.extension.once(event, (...args) => resolve(args)); + }); +} + +async function assertHasPersistedScriptsCachedFlag(ext) { + const { StartupCache } = ExtensionParent; + const allCachedGeneral = StartupCache._data.get("general"); + equal( + allCachedGeneral + .get(ext.id) + ?.get(ext.version) + ?.get("scripting") + ?.has("hasPersistedScripts"), + true, + "Expect the StartupCache to include hasPersistedScripts flag" + ); +} + +async function assertIsPersistentScriptsCachedFlag(ext, expectedValue) { + const { StartupCache } = ExtensionParent; + const allCachedGeneral = StartupCache._data.get("general"); + equal( + allCachedGeneral + .get(ext.id) + ?.get(ext.version) + ?.get("scripting") + ?.get("hasPersistedScripts"), + expectedValue, + "Expected cached value set on hasPersistedScripts flag" + ); +} + +function setup_crash_reporter_override_and_cleaner() { + const crashIds = []; + // Override CrashService.sys.mjs to intercept crash dumps, for two reasons: + // + // - The standard CrashService.sys.mjs implementation uses nsICrashReporter + // through Services.appinfo. Because appinfo has been overridden with an + // incomplete implementation, a promise rejection is triggered when a + // missing method is called at https://searchfox.org/mozilla-central/rev/c615dc4db129ece5cce6c96eb8cab8c5a3e26ac3/toolkit/components/crashes/CrashService.sys.mjs#183 + // + // - We want to intercept the generated crash dumps for expected crashes and + // remove them, to prevent the xpcshell test runner from misinterpreting + // them as "CRASH" failures. + let mockClassId = MockRegistrar.register("@mozilla.org/crashservice;1", { + addCrash(processType, crashType, id) { + // The files are ready to be removed now. We however postpone cleanup + // until the end of the test, to minimize noise during the test, and to + // ensure that the cleanup completes fully. + crashIds.push(id); + }, + QueryInterface: ChromeUtils.generateQI(["nsICrashService"]), + }); + registerCleanupFunction(async () => { + MockRegistrar.unregister(mockClassId); + + // Cannot use Services.appinfo because createAppInfo overrides it. + // eslint-disable-next-line mozilla/use-services + const appinfo = Cc["@mozilla.org/toolkit/crash-reporter;1"].getService( + Ci.nsICrashReporter + ); + + info(`Observed ${crashIds.length} crash dump(s).`); + let deletedCount = 0; + for (let id of crashIds) { + info(`Checking whether dumpID ${id} should be removed`); + let minidumpFile = appinfo.getMinidumpForID(id); + let extraFile = appinfo.getExtraFileForID(id); + let extra; + try { + extra = await IOUtils.readJSON(extraFile.path); + } catch (e) { + info(`Cannot parse crash metadata from ${extraFile.path} :: ${e}\n`); + continue; + } + // The "BrowserTestUtils:CrashFrame" handler annotates the crash + // report before triggering a crash. + if (extra.TestKey !== "CrashFrame") { + info(`Keeping ${minidumpFile.path}; we did not trigger the crash`); + continue; + } + info(`Deleting minidump ${minidumpFile.path} and ${extraFile.path}`); + minidumpFile.remove(false); + extraFile.remove(false); + ++deletedCount; + } + info(`Removed ${deletedCount} crash dumps out of ${crashIds.length}`); + }); +} + +// Crashes a 's remote process. +// Based on BrowserTestUtils.crashFrame. +function crashFrame(browser) { + if (!browser.isRemoteBrowser) { + // The browser should be remote, or the test runner would be killed. + throw new Error(" must be remote"); + } + + const { BrowserTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" + ); + + // Trigger crash by sending a message to BrowserTestUtils actor. + BrowserTestUtils.sendAsyncMessage( + browser.browsingContext, + "BrowserTestUtils:CrashFrame", + {} + ); +} + +/** + * Crash background page of browser and wait for the crash to have been + * detected and processed by ext-backgroundPage.js. + * + * @param {ExtensionWrapper} extension + * @param {XULElement} [bgBrowser] - The background browser. Optional, but must + * be set if the background's ProxyContextParent has not been initialized yet. + */ +async function crashExtensionBackground(extension, bgBrowser) { + bgBrowser ??= extension.extension.backgroundContext.xulBrowser; + + let byeProm = promiseExtensionEvent(extension, "shutdown-background-script"); + if (WebExtensionPolicy.useRemoteWebExtensions) { + info("Killing background page through process crash."); + crashFrame(bgBrowser); + } else { + // If extensions are not running in out-of-process mode, then the + // non-remote process should not be killed (or the test runner dies). + // Remove instead, to simulate the immediate disconnection + // of the message manager (that would happen if the process crashed). + info("Closing background page by destroying ."); + + if (extension.extension.backgroundState === "running") { + // TODO bug 1844217: remove this whole if-block When close() is hooked up + // to setBgStateStopped. It currently is not, and browser destruction is + // currently not detected by the implementation. + let messageManager = bgBrowser.messageManager; + TestUtils.topicObserved( + "message-manager-close", + subject => subject === messageManager + ).then(() => { + Management.emit("extension-process-crash", { childID: 1337 }); + }); + } + bgBrowser.remove(); + } + + info("Waiting for crash to be detected by the internals"); + await byeProm; +} diff --git a/toolkit/components/extensions/test/xpcshell/head_dnr.js b/toolkit/components/extensions/test/xpcshell/head_dnr.js new file mode 100644 index 0000000000..0c65869722 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_dnr.js @@ -0,0 +1,203 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* exported assertDNRStoreData, getDNRRule, getSchemaNormalizedRule, getSchemaNormalizedRules + */ + +ChromeUtils.defineESModuleGetters(this, { + Schemas: "resource://gre/modules/Schemas.sys.mjs", +}); + +function getDNRRule({ + id = 1, + priority = 1, + action = {}, + condition = {}, +} = {}) { + return { + id, + priority, + action: { + type: "block", + ...action, + }, + condition: { + ...condition, + }, + }; +} + +const getSchemaNormalizedRule = (extensionTestWrapper, value) => { + const { extension } = extensionTestWrapper; + const validationContext = { + url: extension.baseURI.spec, + principal: extension.principal, + logError: err => { + // We don't expect this test helper function to be called on invalid rules, + // and so we trigger an explicit test failure if we ever hit any. + Assert.ok( + false, + `Unexpected logError on normalizing DNR rule ${JSON.stringify( + value + )} - ${err}` + ); + }, + preprocessors: {}, + manifestVersion: extension.manifestVersion, + }; + + return Schemas.normalize( + value, + "declarativeNetRequest.Rule", + validationContext + ); +}; + +const getSchemaNormalizedRules = (extensionTestWrapper, rules) => { + return rules.map(rule => { + const normalized = getSchemaNormalizedRule(extensionTestWrapper, rule); + if (normalized.error) { + throw new Error( + `Unexpected DNR Rule normalization error: ${normalized.error}` + ); + } + return normalized.value; + }); +}; + +const assertDNRStoreData = async ( + dnrStore, + extensionTestWrapper, + expectedRulesets, + { assertIndividualRules = true } = {} +) => { + const extUUID = extensionTestWrapper.uuid; + const rule_resources = + extensionTestWrapper.extension.manifest.declarative_net_request + ?.rule_resources; + const expectedRulesetIds = Array.from(Object.keys(expectedRulesets)); + const expectedRulesetIndexesMap = expectedRulesetIds.reduce((acc, rsId) => { + acc.set( + rsId, + rule_resources.findIndex(rr => rr.id === rsId) + ); + return acc; + }, new Map()); + + ok( + dnrStore._dataPromises.has(extUUID), + "Got promise for the test extension DNR data being loaded" + ); + + await dnrStore._dataPromises.get(extUUID); + + ok(dnrStore._data.has(extUUID), "Got data for the test extension"); + + const dnrExtData = dnrStore._data.get(extUUID); + Assert.deepEqual( + { + schemaVersion: dnrExtData.schemaVersion, + extVersion: dnrExtData.extVersion, + }, + { + schemaVersion: dnrExtData.constructor.VERSION, + extVersion: extensionTestWrapper.extension.version, + }, + "Got the expected data schema version and extension version in the store data" + ); + Assert.deepEqual( + Array.from(dnrExtData.staticRulesets.keys()), + expectedRulesetIds, + "Got the enabled rulesets in the stored data staticRulesets Map" + ); + + for (const rulesetId of expectedRulesetIds) { + const expectedRulesetIdx = expectedRulesetIndexesMap.get(rulesetId); + const expectedRulesetRules = getSchemaNormalizedRules( + extensionTestWrapper, + expectedRulesets[rulesetId] + ); + const actualData = dnrExtData.staticRulesets.get(rulesetId); + equal( + actualData.idx, + expectedRulesetIdx, + `Got the expected ruleset index for ruleset id ${rulesetId}` + ); + + // Asserting an entire array of rules all at once will produce + // a big enough output to don't be immediately useful to investigate + // failures, asserting each rule individually would produce more + // readable assertion failure logs. + const assertRuleAtIdx = ruleIdx => { + const actualRule = actualData.rules[ruleIdx]; + const expectedRule = expectedRulesetRules[ruleIdx]; + Assert.deepEqual( + actualRule, + expectedRule, + `Got the expected rule at index ${ruleIdx} for ruleset id "${rulesetId}"` + ); + Assert.equal( + actualRule.constructor.name, + "Rule", + `Expect rule at index ${ruleIdx} to be an instance of the Rule class` + ); + if (expectedRule.condition.regexFilter) { + const compiledRegexFilter = + actualData.rules[ruleIdx].condition.getCompiledRegexFilter(); + Assert.equal( + compiledRegexFilter?.constructor.name, + "RegExp", + `Expect rule ${ruleIdx} condition.getCompiledRegexFilter() to return a compiled regexp filter` + ); + Assert.equal( + compiledRegexFilter?.source, + new RegExp(expectedRule.condition.regexFilter).source, + `Expect rule ${ruleIdx} condition's compiled RegExp source to match the regexFilter string` + ); + Assert.equal( + compiledRegexFilter?.ignoreCase, + !expectedRule.condition.isUrlFilterCaseSensitive, + `Expect rule ${ruleIdx} conditions's compiled RegExp ignoreCase to be set based on condition.isUrlFilterCaseSensitive` + ); + } + }; + + // Some tests may be using a big enough number of rules that + // the assertiongs would be producing a huge amount of log spam, + // and so for those tests we only explicitly assert the first + // and last rule and that the total amount of rules matches the + // expected number of rules (there are still other tests explicitly + // asserting all loaded rules). + if (assertIndividualRules) { + info(`Verify each individual rule loaded for ruleset id "${rulesetId}"`); + for (let ruleIdx = 0; ruleIdx < expectedRulesetRules.length; ruleIdx++) { + assertRuleAtIdx(ruleIdx); + } + } else if (expectedRulesetRules.length) { + // NOTE: Only asserting the first and last rule also helps to speed up + // the test is some slower builds when the number of expected rules is + // big enough (e.g. the test task verifying the enforced rule count limits + // was timing out in tsan build because asserting all indidual rules was + // taking long enough and the event page was being suspended on the idle + // timeout by the time we did run all these assertion and proceeding with + // the rest of the test task assertions), we still confirm that the total + // number of expected vs actual rules also matches right after these + // assertions. + info( + `Verify the first and last rules loaded for ruleset id "${rulesetId}"` + ); + const lastExpectedRuleIdx = expectedRulesetRules.length - 1; + for (const ruleIdx of [0, lastExpectedRuleIdx]) { + assertRuleAtIdx(ruleIdx); + } + } + + equal( + actualData.rules.length, + expectedRulesetRules.length, + `Got the expected number of rules loaded for ruleset id "${rulesetId}"` + ); + } +}; diff --git a/toolkit/components/extensions/test/xpcshell/head_legacy_ep.js b/toolkit/components/extensions/test/xpcshell/head_legacy_ep.js new file mode 100644 index 0000000000..8bb39c0452 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_legacy_ep.js @@ -0,0 +1,13 @@ +"use strict"; + +// Bug 1646182: Test the legacy ExtensionPermission backend until we fully +// migrate to rkv + +{ + const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" + ); + + ExtensionPermissions._useLegacyStorageBackend = true; + ExtensionPermissions._uninit(); +} diff --git a/toolkit/components/extensions/test/xpcshell/head_native_messaging.js b/toolkit/components/extensions/test/xpcshell/head_native_messaging.js new file mode 100644 index 0000000000..32b6948033 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_native_messaging.js @@ -0,0 +1,152 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* globals AppConstants, FileUtils */ +/* exported getSubprocessCount, setupHosts, waitForSubprocessExit */ + +ChromeUtils.defineESModuleGetters(this, { + MockRegistry: "resource://testing-common/MockRegistry.sys.mjs", +}); +if (AppConstants.platform == "win") { + ChromeUtils.defineESModuleGetters(this, { + SubprocessImpl: "resource://gre/modules/subprocess/subprocess_win.sys.mjs", + }); +} else { + ChromeUtils.defineESModuleGetters(this, { + SubprocessImpl: "resource://gre/modules/subprocess/subprocess_unix.sys.mjs", + }); +} + +const { Subprocess } = ChromeUtils.importESModule( + "resource://gre/modules/Subprocess.sys.mjs" +); + +// It's important that we use a space in this directory name to make sure we +// correctly handle executing batch files with spaces in their path. +let tmpDir = FileUtils.getDir("TmpD", ["Native Messaging"]); +tmpDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + +const TYPE_SLUG = + AppConstants.platform === "linux" + ? "native-messaging-hosts" + : "NativeMessagingHosts"; + +add_setup(async function setup() { + await IOUtils.makeDirectory(PathUtils.join(tmpDir.path, TYPE_SLUG)); +}); + +registerCleanupFunction(async () => { + await IOUtils.remove(tmpDir.path, { recursive: true }); +}); + +function getPath(filename) { + return PathUtils.join(tmpDir.path, TYPE_SLUG, filename); +} + +const ID = "native@tests.mozilla.org"; + +async function setupHosts(scripts) { + const pythonPath = await Subprocess.pathSearch(Services.env.get("PYTHON")); + + async function writeManifest(script, scriptPath, path) { + let body = `#!${pythonPath} -u\n${script.script}`; + + await IOUtils.writeUTF8(scriptPath, body); + await IOUtils.setPermissions(scriptPath, 0o755); + + let manifest = { + name: script.name, + description: script.description, + path, + type: "stdio", + allowed_extensions: [ID], + }; + + // Optionally, allow the test to change the manifest before writing. + script._hookModifyManifest?.(manifest); + + let manifestPath = getPath(`${script.name}.json`); + await IOUtils.writeJSON(manifestPath, manifest); + + return manifestPath; + } + + switch (AppConstants.platform) { + case "macosx": + case "linux": + let dirProvider = { + getFile(property) { + if (property == "XREUserNativeManifests") { + return tmpDir.clone(); + } else if (property == "XRESysNativeManifests") { + return tmpDir.clone(); + } + return null; + }, + }; + + Services.dirsvc.registerProvider(dirProvider); + registerCleanupFunction(() => { + Services.dirsvc.unregisterProvider(dirProvider); + }); + + for (let script of scripts) { + let path = getPath(`${script.name}.py`); + + await writeManifest(script, path, path); + } + break; + + case "win": + const REGKEY = String.raw`Software\Mozilla\NativeMessagingHosts`; + + let registry = new MockRegistry(); + registerCleanupFunction(() => { + registry.shutdown(); + }); + + for (let script of scripts) { + let { scriptExtension = "bat" } = script; + + // It's important that we use a space in this filename. See directory + // name comment above. + let batPath = getPath(`batch ${script.name}.${scriptExtension}`); + let scriptPath = getPath(`${script.name}.py`); + + let batBody = `@ECHO OFF\n${pythonPath} -u "${scriptPath}" %*\n`; + await IOUtils.writeUTF8(batPath, batBody); + + let manifestPath = await writeManifest(script, scriptPath, batPath); + + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGKEY}\\${script.name}`, + "", + manifestPath + ); + } + break; + + default: + ok( + false, + `Native messaging is not supported on ${AppConstants.platform}` + ); + } +} + +function getSubprocessCount() { + return SubprocessImpl.Process.getWorker() + .call("getProcesses", []) + .then(result => result.size); +} +function waitForSubprocessExit() { + return SubprocessImpl.Process.getWorker() + .call("waitForNoProcesses", []) + .then(() => { + // Return to the main event loop to give IO handlers enough time to consume + // their remaining buffered input. + return new Promise(resolve => setTimeout(resolve, 0)); + }); +} diff --git a/toolkit/components/extensions/test/xpcshell/head_remote.js b/toolkit/components/extensions/test/xpcshell/head_remote.js new file mode 100644 index 0000000000..f9c31144c9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_remote.js @@ -0,0 +1,7 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.webextensions.remote", true); +Services.prefs.setIntPref("dom.ipc.keepProcessesAlive.extension", 1); + +/* globals testEnv */ +testEnv.expectRemote = true; // tested in head_test.js diff --git a/toolkit/components/extensions/test/xpcshell/head_schemas.js b/toolkit/components/extensions/test/xpcshell/head_schemas.js new file mode 100644 index 0000000000..94af4a631a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_schemas.js @@ -0,0 +1,129 @@ +"use strict"; + +/* exported Schemas, LocalAPIImplementation, SchemaAPIInterface, getContextWrapper */ + +const { Schemas } = ChromeUtils.importESModule( + "resource://gre/modules/Schemas.sys.mjs" +); + +const { ExtensionCommon } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionCommon.sys.mjs" +); + +let { LocalAPIImplementation, SchemaAPIInterface } = ExtensionCommon; + +const contextCloneScope = this; + +class TallyingAPIImplementation extends SchemaAPIInterface { + constructor(context, namespace, name) { + super(); + this.namespace = namespace; + this.name = name; + this.context = context; + } + + callFunction(args) { + this.context.tally("call", this.namespace, this.name, args); + if (this.name === "sub_foo") { + return 13; + } + } + + callFunctionNoReturn(args) { + this.context.tally("call", this.namespace, this.name, args); + } + + getProperty() { + this.context.tally("get", this.namespace, this.name); + } + + setProperty(value) { + this.context.tally("set", this.namespace, this.name, value); + } + + addListener(listener, args) { + this.context.tally("addListener", this.namespace, this.name, [ + listener, + args, + ]); + } + + removeListener(listener) { + this.context.tally("removeListener", this.namespace, this.name, [listener]); + } + + hasListener(listener) { + this.context.tally("hasListener", this.namespace, this.name, [listener]); + } +} + +function getContextWrapper(manifestVersion = 2) { + return { + url: "moz-extension://b66e3509-cdb3-44f6-8eb8-c8b39b3a1d27/", + + cloneScope: contextCloneScope, + + manifestVersion, + + permissions: new Set(), + tallied: null, + talliedErrors: [], + + tally(kind, ns, name, args) { + this.tallied = [kind, ns, name, args]; + }, + + verify(...args) { + Assert.equal(JSON.stringify(this.tallied), JSON.stringify(args)); + this.tallied = null; + }, + + checkErrors(errors) { + let { talliedErrors } = this; + Assert.equal( + talliedErrors.length, + errors.length, + "Got expected number of errors" + ); + for (let [i, error] of errors.entries()) { + Assert.ok( + i in talliedErrors && String(talliedErrors[i]).includes(error), + `${JSON.stringify(error)} is a substring of error ${JSON.stringify( + talliedErrors[i] + )}` + ); + } + + talliedErrors.length = 0; + }, + + checkLoadURL(url) { + return !url.startsWith("chrome:"); + }, + + preprocessors: { + localize(value, context) { + return value.replace( + /__MSG_(.*?)__/g, + (m0, m1) => `${m1.toUpperCase()}` + ); + }, + }, + + logError(message) { + this.talliedErrors.push(message); + }, + + hasPermission(permission) { + return this.permissions.has(permission); + }, + + shouldInject(ns, name, allowedContexts) { + return name != "do-not-inject"; + }, + + getImplementation(namespace, name) { + return new TallyingAPIImplementation(this, namespace, name); + }, + }; +} diff --git a/toolkit/components/extensions/test/xpcshell/head_service_worker.js b/toolkit/components/extensions/test/xpcshell/head_service_worker.js new file mode 100644 index 0000000000..aa1cf5cb18 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_service_worker.js @@ -0,0 +1,158 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported TestWorkerWatcher */ + +ChromeUtils.defineESModuleGetters(this, { + ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", +}); + +// Ensure that the profile-after-change message has been notified, +// so that ServiceWokerRegistrar is going to be initialized, +// otherwise tests using a background service worker will fail. +// in debug builds because of an assertion failure triggered +// by ServiceWorkerRegistrar.cpp (due to not being initialized +// automatically on startup as in a real Firefox instance). +Services.obs.notifyObservers( + null, + "profile-after-change", + "force-serviceworkerrestart-init" +); + +// A test utility class used in the test case to watch for a given extension +// service worker being spawned and terminated (using the same kind of Firefox DevTools +// internals that about:debugging is using to watch the workers activity). +// +// NOTE: this helper class does also depends from the two jsm files where the +// Parent and Child TestWorkerWatcher actor is defined: +// +// - data/TestWorkerWatcherParent.sys.mjs +// - data/TestWorkerWatcherChild.sys.mjs +class TestWorkerWatcher extends ExtensionCommon.EventEmitter { + JS_ACTOR_NAME = "TestWorkerWatcher"; + + constructor(dataRelPath = "./data") { + super(); + this.dataRelPath = dataRelPath; + this.extensionProcess = null; + this.extensionProcessActor = null; + this.registerProcessActor(); + this.getAndWatchExtensionProcess(); + // Observer child process creation and shutdown if the extension + // are meant to run in a child process. + Services.obs.addObserver(this, "ipc:content-created"); + Services.obs.addObserver(this, "ipc:content-shutdown"); + } + + async destroy() { + await this.stopWatchingWorkers(); + ChromeUtils.unregisterProcessActor(this.JS_ACTOR_NAME); + } + + get swm() { + return Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + } + + getRegistration(extension) { + return this.swm.getRegistrationByPrincipal( + extension.extension.principal, + extension.extension.principal.spec + ); + } + + watchExtensionServiceWorker(extension) { + // These events are emitted by TestWatchExtensionWorkersParent. + const promiseWorkerSpawned = this.waitForEvent("worker-spawned", extension); + const promiseWorkerTerminated = this.waitForEvent( + "worker-terminated", + extension + ); + + // Terminate the worker sooner by settng the idle_timeout to 0, + // then clear the pref as soon as the worker has been terminated. + const terminate = () => { + promiseWorkerTerminated.then(() => { + Services.prefs.clearUserPref("dom.serviceWorkers.idle_timeout"); + }); + Services.prefs.setIntPref("dom.serviceWorkers.idle_timeout", 0); + const swReg = this.getRegistration(extension); + // If the active worker is already active, we have to make sure the new value + // set on the idle_timeout pref is picked up by ServiceWorkerPrivate::ResetIdleTimeout. + swReg.activeWorker?.attachDebugger(); + swReg.activeWorker?.detachDebugger(); + return promiseWorkerTerminated; + }; + + return { + promiseWorkerSpawned, + promiseWorkerTerminated, + terminate, + }; + } + + // Methods only used internally. + + waitForEvent(event, extension) { + return new Promise(resolve => { + const listener = (_eventName, data) => { + if (!data.workerUrl.startsWith(extension.extension?.principal.spec)) { + return; + } + this.off(event, listener); + resolve(data); + }; + + this.on(event, listener); + }); + } + + registerProcessActor() { + const { JS_ACTOR_NAME } = this; + ChromeUtils.registerProcessActor(JS_ACTOR_NAME, { + parent: { + esModuleURI: `resource://testing-common/${JS_ACTOR_NAME}Parent.sys.mjs`, + }, + child: { + esModuleURI: `resource://testing-common/${JS_ACTOR_NAME}Child.sys.mjs`, + }, + }); + } + + startWatchingWorkers() { + if (!this.extensionProcessActor) { + return; + } + this.extensionProcessActor.eventEmitter = this; + return this.extensionProcessActor.sendQuery("Test:StartWatchingWorkers"); + } + + stopWatchingWorkers() { + if (!this.extensionProcessActor) { + return; + } + this.extensionProcessActor.eventEmitter = null; + return this.extensionProcessActor.sendQuery("Test:StopWatchingWorkers"); + } + + getAndWatchExtensionProcess() { + const extensionProcess = ChromeUtils.getAllDOMProcesses().find(p => { + return p.remoteType === "extension"; + }); + if (extensionProcess !== this.extensionProcess) { + this.extensionProcess = extensionProcess; + this.extensionProcessActor = extensionProcess + ? extensionProcess.getActor(this.JS_ACTOR_NAME) + : null; + this.startWatchingWorkers(); + } + } + + observe(subject, topic, childIDString) { + // Keep the watched process and related test child process actor updated + // when a process is created or destroyed. + this.getAndWatchExtensionProcess(); + } +} diff --git a/toolkit/components/extensions/test/xpcshell/head_storage.js b/toolkit/components/extensions/test/xpcshell/head_storage.js new file mode 100644 index 0000000000..139c84bf8d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_storage.js @@ -0,0 +1,1400 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* import-globals-from head.js */ + +const STORAGE_SYNC_PREF = "webextensions.storage.sync.enabled"; + +// Test implementations and utility functions that are used against multiple +// storage areas (eg, a test which is run against browser.storage.local and +// browser.storage.sync, or a test against browser.storage.sync but needs to +// be run against both the kinto and rust implementations.) + +/** + * Utility function to ensure that all supported APIs for getting are + * tested. + * + * @param {string} areaName + * either "local" or "sync" according to what we want to test + * @param {string} prop + * "key" to look up using the storage API + * @param {object} value + * "value" to compare against + */ +async function checkGetImpl(areaName, prop, value) { + let storage = browser.storage[areaName]; + + let data = await storage.get(); + browser.test.assertEq( + value, + data[prop], + `unspecified getter worked for ${prop} in ${areaName}` + ); + + data = await storage.get(null); + browser.test.assertEq( + value, + data[prop], + `null getter worked for ${prop} in ${areaName}` + ); + + data = await storage.get(prop); + browser.test.assertEq( + value, + data[prop], + `string getter worked for ${prop} in ${areaName}` + ); + browser.test.assertEq( + Object.keys(data).length, + 1, + `string getter should return an object with a single property` + ); + + data = await storage.get([prop]); + browser.test.assertEq( + value, + data[prop], + `array getter worked for ${prop} in ${areaName}` + ); + browser.test.assertEq( + Object.keys(data).length, + 1, + `array getter with a single key should return an object with a single property` + ); + + data = await storage.get({ [prop]: undefined }); + browser.test.assertEq( + value, + data[prop], + `object getter worked for ${prop} in ${areaName}` + ); + browser.test.assertEq( + Object.keys(data).length, + 1, + `object getter with a single key should return an object with a single property` + ); +} + +function test_config_flag_needed() { + async function testFn() { + function background() { + let promises = []; + let apiTests = [ + { method: "get", args: ["foo"] }, + { method: "set", args: [{ foo: "bar" }] }, + { method: "remove", args: ["foo"] }, + { method: "clear", args: [] }, + ]; + apiTests.forEach(testDef => { + promises.push( + browser.test.assertRejects( + browser.storage.sync[testDef.method](...testDef.args), + "Please set webextensions.storage.sync.enabled to true in about:config", + `storage.sync.${testDef.method} is behind a flag` + ) + ); + }); + + Promise.all(promises).then(() => browser.test.notifyPass("flag needed")); + } + + ok( + !Services.prefs.getBoolPref(STORAGE_SYNC_PREF, false), + "The `${STORAGE_SYNC_PREF}` should be set to false" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + }, + background, + }); + + await extension.startup(); + await extension.awaitFinish("flag needed"); + await extension.unload(); + } + + return runWithPrefs([[STORAGE_SYNC_PREF, false]], testFn); +} + +async function test_storage_after_reload(areaName, { expectPersistency }) { + // Just some random extension ID that we can re-use + const extensionId = "my-extension-id@1"; + + function loadExtension() { + async function background(areaName) { + browser.test.sendMessage( + "initialItems", + await browser.storage[areaName].get(null) + ); + await browser.storage[areaName].set({ a: "b" }); + browser.test.notifyPass("set-works"); + } + + return ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: extensionId } }, + permissions: ["storage"], + }, + background: `(${background})("${areaName}")`, + }); + } + + let extension1 = loadExtension(); + await extension1.startup(); + + Assert.deepEqual( + await extension1.awaitMessage("initialItems"), + {}, + "No stored items at first" + ); + + await extension1.awaitFinish("set-works"); + await extension1.unload(); + + let extension2 = loadExtension(); + await extension2.startup(); + + Assert.deepEqual( + await extension2.awaitMessage("initialItems"), + expectPersistency ? { a: "b" } : {}, + `Expect ${areaName} stored items ${ + expectPersistency ? "to" : "not" + } be available after restart` + ); + + await extension2.awaitFinish("set-works"); + await extension2.unload(); +} + +function test_sync_reloading_extensions_works() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], async () => { + ok( + Services.prefs.getBoolPref(STORAGE_SYNC_PREF, false), + "The `${STORAGE_SYNC_PREF}` should be set to true" + ); + + await test_storage_after_reload("sync", { expectPersistency: true }); + }); +} + +async function test_background_page_storage(testAreaName) { + async function backgroundScript(checkGet) { + let globalChanges, gResolve; + function clearGlobalChanges() { + globalChanges = new Promise(resolve => { + gResolve = resolve; + }); + } + clearGlobalChanges(); + let expectedAreaName; + + browser.storage.onChanged.addListener((changes, areaName) => { + browser.test.assertEq( + expectedAreaName, + areaName, + "Expected area name received by listener" + ); + gResolve(changes); + }); + + async function checkChanges(areaName, changes, message) { + function checkSub(obj1, obj2) { + for (let prop in obj1) { + browser.test.assertTrue( + obj1[prop] !== undefined, + `checkChanges ${areaName} ${prop} is missing (${message})` + ); + browser.test.assertTrue( + obj2[prop] !== undefined, + `checkChanges ${areaName} ${prop} is missing (${message})` + ); + browser.test.assertEq( + obj1[prop].oldValue, + obj2[prop].oldValue, + `checkChanges ${areaName} ${prop} old (${message})` + ); + browser.test.assertEq( + obj1[prop].newValue, + obj2[prop].newValue, + `checkChanges ${areaName} ${prop} new (${message})` + ); + } + } + + const recentChanges = await globalChanges; + checkSub(changes, recentChanges); + checkSub(recentChanges, changes); + clearGlobalChanges(); + } + + // Regression test for https://bugzilla.mozilla.org/show_bug.cgi?id=1645598 + async function testNonExistingKeys(storage, storageAreaDesc) { + let data = await storage.get({ test6: 6 }); + browser.test.assertEq( + `{"test6":6}`, + JSON.stringify(data), + `Use default value when not stored for ${storageAreaDesc}` + ); + + data = await storage.get({ test6: null }); + browser.test.assertEq( + `{"test6":null}`, + JSON.stringify(data), + `Use default value, even if null for ${storageAreaDesc}` + ); + + data = await storage.get("test6"); + browser.test.assertEq( + `{}`, + JSON.stringify(data), + `Empty result if key is not found for ${storageAreaDesc}` + ); + + data = await storage.get(["test6", "test7"]); + browser.test.assertEq( + `{}`, + JSON.stringify(data), + `Empty result if list of keys is not found for ${storageAreaDesc}` + ); + } + + async function testFalseyValues(areaName) { + let storage = browser.storage[areaName]; + const dataInitial = { + "test-falsey-value-bool": false, + "test-falsey-value-string": "", + "test-falsey-value-number": 0, + }; + const dataUpdate = { + "test-falsey-value-bool": true, + "test-falsey-value-string": "non-empty-string", + "test-falsey-value-number": 10, + }; + + // Compute the expected changes. + const onSetInitial = { + "test-falsey-value-bool": { newValue: false }, + "test-falsey-value-string": { newValue: "" }, + "test-falsey-value-number": { newValue: 0 }, + }; + const onRemovedFalsey = { + "test-falsey-value-bool": { oldValue: false }, + "test-falsey-value-string": { oldValue: "" }, + "test-falsey-value-number": { oldValue: 0 }, + }; + const onUpdatedFalsey = { + "test-falsey-value-bool": { newValue: true, oldValue: false }, + "test-falsey-value-string": { + newValue: "non-empty-string", + oldValue: "", + }, + "test-falsey-value-number": { newValue: 10, oldValue: 0 }, + }; + const keys = Object.keys(dataInitial); + + // Test on removing falsey values. + await storage.set(dataInitial); + await checkChanges(areaName, onSetInitial, "set falsey values"); + await storage.remove(keys); + await checkChanges(areaName, onRemovedFalsey, "remove falsey value"); + + // Test on updating falsey values. + await storage.set(dataInitial); + await checkChanges(areaName, onSetInitial, "set falsey values"); + await storage.set(dataUpdate); + await checkChanges(areaName, onUpdatedFalsey, "set non-falsey values"); + + // Clear the storage state. + await testNonExistingKeys(storage, `${areaName} before clearing`); + await storage.clear(); + await testNonExistingKeys(storage, `${areaName} after clearing`); + await globalChanges; + clearGlobalChanges(); + } + + function CustomObj() { + this.testKey1 = "testValue1"; + } + + CustomObj.prototype.toString = function () { + return '{"testKey2":"testValue2"}'; + }; + + CustomObj.prototype.toJSON = function customObjToJSON() { + return { testKey1: "testValue3" }; + }; + + /* eslint-disable dot-notation */ + async function runTests(areaName) { + expectedAreaName = areaName; + let storage = browser.storage[areaName]; + // Set some data and then test getters. + try { + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + await checkChanges( + areaName, + { + "test-prop1": { newValue: "value1" }, + "test-prop2": { newValue: "value2" }, + }, + "set (a)" + ); + + await checkGet(areaName, "test-prop1", "value1"); + await checkGet(areaName, "test-prop2", "value2"); + + let data = await storage.get({ + "test-prop1": undefined, + "test-prop2": undefined, + other: "default", + }); + browser.test.assertEq( + "value1", + data["test-prop1"], + "prop1 correct (a)" + ); + browser.test.assertEq( + "value2", + data["test-prop2"], + "prop2 correct (a)" + ); + browser.test.assertEq("default", data["other"], "other correct"); + + data = await storage.get(["test-prop1", "test-prop2", "other"]); + browser.test.assertEq( + "value1", + data["test-prop1"], + "prop1 correct (b)" + ); + browser.test.assertEq( + "value2", + data["test-prop2"], + "prop2 correct (b)" + ); + browser.test.assertFalse("other" in data, "other correct"); + + // Remove data in various ways. + await storage.remove("test-prop1"); + await checkChanges( + areaName, + { "test-prop1": { oldValue: "value1" } }, + "remove string" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse( + "test-prop1" in data, + "prop1 absent (remove string)" + ); + browser.test.assertTrue( + "test-prop2" in data, + "prop2 present (remove string)" + ); + + await storage.set({ "test-prop1": "value1" }); + await checkChanges( + areaName, + { "test-prop1": { newValue: "value1" } }, + "set (c)" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertEq( + data["test-prop1"], + "value1", + "prop1 correct (c)" + ); + browser.test.assertEq( + data["test-prop2"], + "value2", + "prop2 correct (c)" + ); + + await storage.remove(["test-prop1", "test-prop2"]); + await checkChanges( + areaName, + { + "test-prop1": { oldValue: "value1" }, + "test-prop2": { oldValue: "value2" }, + }, + "remove array" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse( + "test-prop1" in data, + "prop1 absent (remove array)" + ); + browser.test.assertFalse( + "test-prop2" in data, + "prop2 absent (remove array)" + ); + + await testFalseyValues(areaName); + + // test storage.clear + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + // Make sure that set() handler happened before we clear the + // promise again. + await globalChanges; + + clearGlobalChanges(); + await storage.clear(); + + await checkChanges( + areaName, + { + "test-prop1": { oldValue: "value1" }, + "test-prop2": { oldValue: "value2" }, + }, + "clear" + ); + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse("test-prop1" in data, "prop1 absent (clear)"); + browser.test.assertFalse("test-prop2" in data, "prop2 absent (clear)"); + + // Make sure we can store complex JSON data. + // known previous values + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + + // Make sure the set() handler landed. + await globalChanges; + + let date = new Date(0); + + clearGlobalChanges(); + await storage.set({ + "test-prop1": { + str: "hello", + bool: true, + null: null, + undef: undefined, + obj: {}, + nestedObj: { + testKey: {}, + }, + intKeyObj: { + 4: "testValue1", + 3: "testValue2", + 99: "testValue3", + }, + floatKeyObj: { + 1.4: "testValue1", + 5.5: "testValue2", + }, + customObj: new CustomObj(), + arr: [1, 2], + nestedArr: [1, [2, 3]], + date, + regexp: /regexp/, + }, + }); + + await browser.test.assertRejects( + storage.set({ + window, + }), + /DataCloneError|cyclic object value/ + ); + + await browser.test.assertRejects( + storage.set({ "test-prop2": function func() {} }), + /DataCloneError/ + ); + + const recentChanges = await globalChanges; + + browser.test.assertEq( + "value1", + recentChanges["test-prop1"].oldValue, + "oldValue correct" + ); + browser.test.assertEq( + "object", + typeof recentChanges["test-prop1"].newValue, + "newValue is obj" + ); + clearGlobalChanges(); + + data = await storage.get({ + "test-prop1": undefined, + "test-prop2": undefined, + }); + let obj = data["test-prop1"]; + + browser.test.assertEq( + "object", + typeof obj.customObj, + "custom object part correct" + ); + browser.test.assertEq( + 1, + Object.keys(obj.customObj).length, + "customObj keys correct" + ); + + if (areaName === "local" || areaName === "session") { + browser.test.assertEq( + String(date), + String(obj.date), + "date part correct" + ); + browser.test.assertEq( + "/regexp/", + obj.regexp.toString(), + "regexp part correct" + ); + // storage.local and .session don't use toJSON(). + browser.test.assertEq( + "testValue1", + obj.customObj.testKey1, + "customObj keys correct" + ); + } else { + browser.test.assertEq( + "1970-01-01T00:00:00.000Z", + String(obj.date), + "date part correct" + ); + + browser.test.assertEq( + "object", + typeof obj.regexp, + "regexp part is an object" + ); + browser.test.assertEq( + 0, + Object.keys(obj.regexp).length, + "regexp part is an empty object" + ); + // storage.sync does call toJSON + browser.test.assertEq( + "testValue3", + obj.customObj.testKey1, + "customObj keys correct" + ); + } + + browser.test.assertEq("hello", obj.str, "string part correct"); + browser.test.assertEq(true, obj.bool, "bool part correct"); + browser.test.assertEq(null, obj.null, "null part correct"); + browser.test.assertEq(undefined, obj.undef, "undefined part correct"); + browser.test.assertEq(undefined, obj.window, "window part correct"); + browser.test.assertEq("object", typeof obj.obj, "object part correct"); + browser.test.assertEq( + "object", + typeof obj.nestedObj, + "nested object part correct" + ); + browser.test.assertEq( + "object", + typeof obj.nestedObj.testKey, + "nestedObj.testKey part correct" + ); + browser.test.assertEq( + "object", + typeof obj.intKeyObj, + "int key object part correct" + ); + browser.test.assertEq( + "testValue1", + obj.intKeyObj[4], + "intKeyObj[4] part correct" + ); + browser.test.assertEq( + "testValue2", + obj.intKeyObj[3], + "intKeyObj[3] part correct" + ); + browser.test.assertEq( + "testValue3", + obj.intKeyObj[99], + "intKeyObj[99] part correct" + ); + browser.test.assertEq( + "object", + typeof obj.floatKeyObj, + "float key object part correct" + ); + browser.test.assertEq( + "testValue1", + obj.floatKeyObj[1.4], + "floatKeyObj[1.4] part correct" + ); + browser.test.assertEq( + "testValue2", + obj.floatKeyObj[5.5], + "floatKeyObj[5.5] part correct" + ); + + browser.test.assertTrue(Array.isArray(obj.arr), "array part present"); + browser.test.assertEq(1, obj.arr[0], "arr[0] part correct"); + browser.test.assertEq(2, obj.arr[1], "arr[1] part correct"); + browser.test.assertEq(2, obj.arr.length, "arr.length part correct"); + browser.test.assertTrue( + Array.isArray(obj.nestedArr), + "nested array part present" + ); + browser.test.assertEq( + 2, + obj.nestedArr.length, + "nestedArr.length part correct" + ); + browser.test.assertEq(1, obj.nestedArr[0], "nestedArr[0] part correct"); + browser.test.assertTrue( + Array.isArray(obj.nestedArr[1]), + "nestedArr[1] part present" + ); + browser.test.assertEq( + 2, + obj.nestedArr[1].length, + "nestedArr[1].length part correct" + ); + browser.test.assertEq( + 2, + obj.nestedArr[1][0], + "nestedArr[1][0] part correct" + ); + browser.test.assertEq( + 3, + obj.nestedArr[1][1], + "nestedArr[1][1] part correct" + ); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("storage"); + } + } + + browser.test.onMessage.addListener(msg => { + let promise; + if (msg === "test-local") { + promise = runTests("local"); + } else if (msg === "test-sync") { + promise = runTests("sync"); + } else if (msg === "test-session") { + promise = runTests("session"); + } + promise.then(() => browser.test.sendMessage("test-finished")); + }); + + browser.test.sendMessage("ready"); + } + + let extensionData = { + background: `(${backgroundScript})(${checkGetImpl})`, + manifest: { + permissions: ["storage"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage(`test-${testAreaName}`); + await extension.awaitMessage("test-finished"); + + await extension.unload(); +} + +function test_storage_sync_requires_real_id() { + async function testFn() { + async function background() { + const EXCEPTION_MESSAGE = + "The storage API will not work with a temporary addon ID. " + + "Please add an explicit addon ID to your manifest. " + + "For more information see https://mzl.la/3lPk1aE."; + + await browser.test.assertRejects( + browser.storage.sync.set({ foo: "bar" }), + EXCEPTION_MESSAGE + ); + + browser.test.notifyPass("exception correct"); + } + + let extensionData = { + background, + manifest: { + permissions: ["storage"], + }, + useAddonManager: "temporary", + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("exception correct"); + + await extension.unload(); + } + + return runWithPrefs([[STORAGE_SYNC_PREF, true]], testFn); +} + +// Test for storage areas which don't support getBytesInUse() nor QUOTA +// constants. +async function check_storage_area_no_bytes_in_use(area) { + let impl = browser.storage[area]; + + browser.test.assertEq( + typeof impl.getBytesInUse, + "undefined", + "getBytesInUse API method should not be available" + ); + browser.test.sendMessage("test-complete"); +} + +async function test_background_storage_area_no_bytes_in_use(area) { + const EXT_ID = "test-gbiu@mozilla.org"; + + const extensionDef = { + manifest: { + permissions: ["storage"], + browser_specific_settings: { gecko: { id: EXT_ID } }, + }, + background: `(${check_storage_area_no_bytes_in_use})("${area}")`, + }; + + const extension = ExtensionTestUtils.loadExtension(extensionDef); + + await extension.startup(); + await extension.awaitMessage("test-complete"); + await extension.unload(); +} + +async function test_contentscript_storage_area_no_bytes_in_use(area) { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + function contentScript(checkImpl) { + browser.test.onMessage.addListener(msg => { + if (msg === "test-local") { + checkImpl("local"); + } else if (msg === "test-sync") { + checkImpl("sync"); + } else if (msg === "test-session") { + checkImpl("session"); + } else { + browser.test.fail(`Unexpected test message received: ${msg}`); + browser.test.sendMessage("test-complete"); + } + }); + browser.test.sendMessage("ready"); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + + permissions: ["storage"], + }, + + files: { + "content_script.js": `(${contentScript})(${check_storage_area_no_bytes_in_use})`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage(`test-${area}`); + await extension.awaitMessage("test-complete"); + + await extension.unload(); + await contentPage.close(); +} + +// Test for storage areas which do support getBytesInUse() (but which may or may +// not support enforcement of the quota) +async function check_storage_area_with_bytes_in_use(area, expectQuota) { + let impl = browser.storage[area]; + + // QUOTA_* constants aren't currently exposed - see bug 1396810. + // However, the quotas are still enforced, so test them here. + // (Note that an implication of this is that we can't test area other than + // 'sync', because its limits are different - so for completeness...) + browser.test.assertEq( + area, + "sync", + "Running test on storage.sync API as expected" + ); + const QUOTA_BYTES_PER_ITEM = 8192; + const MAX_ITEMS = 512; + + // bytes is counted as "length of key as a string, length of value as + // JSON" - ie, quotes not counted in the key, but are in the value. + let value = "x".repeat(QUOTA_BYTES_PER_ITEM - 3); + + await impl.set({ x: value }); // Shouldn't reject on either kinto or rust-based storage.sync. + browser.test.assertEq(await impl.getBytesInUse(null), QUOTA_BYTES_PER_ITEM); + // kinto does implement getBytesInUse() but doesn't enforce a quota. + if (expectQuota) { + await browser.test.assertRejects( + impl.set({ x: value + "x" }), + /QuotaExceededError/, + "Got a rejection with the expected error message" + ); + // MAX_ITEMS + await impl.clear(); + let ob = {}; + for (let i = 0; i < MAX_ITEMS; i++) { + ob[`key-${i}`] = "x"; + } + await impl.set(ob); // should work. + await browser.test.assertRejects( + impl.set({ straw: "camel's back" }), // exceeds MAX_ITEMS + /QuotaExceededError/, + "Got a rejection with the expected error message" + ); + // QUOTA_BYTES is being already tested for the underlying StorageSyncService + // so we don't duplicate those tests here. + } else { + // Exceeding quota should work on the previous kinto-based storage.sync implementation + await impl.set({ x: value + "x" }); // exceeds quota but should work. + browser.test.assertEq( + await impl.getBytesInUse(null), + QUOTA_BYTES_PER_ITEM + 1, + "Got the expected result from getBytesInUse" + ); + } + browser.test.sendMessage("test-complete"); +} + +async function test_background_storage_area_with_bytes_in_use( + area, + expectQuota +) { + const EXT_ID = "test-gbiu@mozilla.org"; + + const extensionDef = { + manifest: { + permissions: ["storage"], + browser_specific_settings: { gecko: { id: EXT_ID } }, + }, + background: `(${check_storage_area_with_bytes_in_use})("${area}", ${expectQuota})`, + }; + + const extension = ExtensionTestUtils.loadExtension(extensionDef); + + await extension.startup(); + await extension.awaitMessage("test-complete"); + await extension.unload(); +} + +async function test_contentscript_storage_area_with_bytes_in_use( + area, + expectQuota +) { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + function contentScript(checkImpl) { + browser.test.onMessage.addListener(([area, expectQuota]) => { + if ( + !["local", "sync"].includes(area) || + typeof expectQuota !== "boolean" + ) { + browser.test.fail(`Unexpected test message: [${area}, ${expectQuota}]`); + // Let the test to fail immediately instead of wait for a timeout failure. + browser.test.sendMessage("test-complete"); + return; + } + checkImpl(area, expectQuota); + }); + browser.test.sendMessage("ready"); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + + permissions: ["storage"], + }, + + files: { + "content_script.js": `(${contentScript})(${check_storage_area_with_bytes_in_use})`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage([area, expectQuota]); + await extension.awaitMessage("test-complete"); + + await extension.unload(); + await contentPage.close(); +} + +// A couple of common tests for checking content scripts. +async function testStorageContentScript(checkGet) { + let globalChanges, gResolve; + function clearGlobalChanges() { + globalChanges = new Promise(resolve => { + gResolve = resolve; + }); + } + clearGlobalChanges(); + let expectedAreaName; + + browser.storage.onChanged.addListener((changes, areaName) => { + browser.test.assertEq( + expectedAreaName, + areaName, + "Expected area name received by listener" + ); + gResolve(changes); + }); + + async function checkChanges(areaName, changes, message) { + function checkSub(obj1, obj2) { + for (let prop in obj1) { + browser.test.assertTrue( + obj1[prop] !== undefined, + `checkChanges ${areaName} ${prop} is missing (${message})` + ); + browser.test.assertTrue( + obj2[prop] !== undefined, + `checkChanges ${areaName} ${prop} is missing (${message})` + ); + browser.test.assertEq( + obj1[prop].oldValue, + obj2[prop].oldValue, + `checkChanges ${areaName} ${prop} old (${message})` + ); + browser.test.assertEq( + obj1[prop].newValue, + obj2[prop].newValue, + `checkChanges ${areaName} ${prop} new (${message})` + ); + } + } + + const recentChanges = await globalChanges; + checkSub(changes, recentChanges); + checkSub(recentChanges, changes); + clearGlobalChanges(); + } + + /* eslint-disable dot-notation */ + async function runTests(areaName) { + expectedAreaName = areaName; + let storage = browser.storage[areaName]; + // Set some data and then test getters. + try { + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + await checkChanges( + areaName, + { + "test-prop1": { newValue: "value1" }, + "test-prop2": { newValue: "value2" }, + }, + "set (a)" + ); + + await checkGet(areaName, "test-prop1", "value1"); + await checkGet(areaName, "test-prop2", "value2"); + + let data = await storage.get({ + "test-prop1": undefined, + "test-prop2": undefined, + other: "default", + }); + browser.test.assertEq("value1", data["test-prop1"], "prop1 correct (a)"); + browser.test.assertEq("value2", data["test-prop2"], "prop2 correct (a)"); + browser.test.assertEq("default", data["other"], "other correct"); + + data = await storage.get(["test-prop1", "test-prop2", "other"]); + browser.test.assertEq("value1", data["test-prop1"], "prop1 correct (b)"); + browser.test.assertEq("value2", data["test-prop2"], "prop2 correct (b)"); + browser.test.assertFalse("other" in data, "other correct"); + + // Remove data in various ways. + await storage.remove("test-prop1"); + await checkChanges( + areaName, + { "test-prop1": { oldValue: "value1" } }, + "remove string" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse( + "test-prop1" in data, + "prop1 absent (remove string)" + ); + browser.test.assertTrue( + "test-prop2" in data, + "prop2 present (remove string)" + ); + + await storage.set({ "test-prop1": "value1" }); + await checkChanges( + areaName, + { "test-prop1": { newValue: "value1" } }, + "set (c)" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertEq(data["test-prop1"], "value1", "prop1 correct (c)"); + browser.test.assertEq(data["test-prop2"], "value2", "prop2 correct (c)"); + + await storage.remove(["test-prop1", "test-prop2"]); + await checkChanges( + areaName, + { + "test-prop1": { oldValue: "value1" }, + "test-prop2": { oldValue: "value2" }, + }, + "remove array" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse( + "test-prop1" in data, + "prop1 absent (remove array)" + ); + browser.test.assertFalse( + "test-prop2" in data, + "prop2 absent (remove array)" + ); + + // test storage.clear + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + // Make sure that set() handler happened before we clear the + // promise again. + await globalChanges; + + clearGlobalChanges(); + await storage.clear(); + + await checkChanges( + areaName, + { + "test-prop1": { oldValue: "value1" }, + "test-prop2": { oldValue: "value2" }, + }, + "clear" + ); + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse("test-prop1" in data, "prop1 absent (clear)"); + browser.test.assertFalse("test-prop2" in data, "prop2 absent (clear)"); + + // Make sure we can store complex JSON data. + // known previous values + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + + // Make sure the set() handler landed. + await globalChanges; + + let date = new Date(0); + + clearGlobalChanges(); + await storage.set({ + "test-prop1": { + str: "hello", + bool: true, + null: null, + undef: undefined, + obj: {}, + arr: [1, 2], + date: new Date(0), + regexp: /regexp/, + }, + }); + + await browser.test.assertRejects( + storage.set({ + window, + }), + /DataCloneError|cyclic object value/ + ); + + await browser.test.assertRejects( + storage.set({ "test-prop2": function func() {} }), + /DataCloneError/ + ); + + const recentChanges = await globalChanges; + + browser.test.assertEq( + "value1", + recentChanges["test-prop1"].oldValue, + "oldValue correct" + ); + browser.test.assertEq( + "object", + typeof recentChanges["test-prop1"].newValue, + "newValue is obj" + ); + clearGlobalChanges(); + + data = await storage.get({ + "test-prop1": undefined, + "test-prop2": undefined, + }); + let obj = data["test-prop1"]; + + if (areaName === "local") { + browser.test.assertEq( + String(date), + String(obj.date), + "date part correct" + ); + browser.test.assertEq( + "/regexp/", + obj.regexp.toString(), + "regexp part correct" + ); + } else { + browser.test.assertEq( + "1970-01-01T00:00:00.000Z", + String(obj.date), + "date part correct" + ); + + browser.test.assertEq( + "object", + typeof obj.regexp, + "regexp part is an object" + ); + browser.test.assertEq( + 0, + Object.keys(obj.regexp).length, + "regexp part is an empty object" + ); + } + + browser.test.assertEq("hello", obj.str, "string part correct"); + browser.test.assertEq(true, obj.bool, "bool part correct"); + browser.test.assertEq(null, obj.null, "null part correct"); + browser.test.assertEq(undefined, obj.undef, "undefined part correct"); + browser.test.assertEq(undefined, obj.window, "window part correct"); + browser.test.assertEq("object", typeof obj.obj, "object part correct"); + browser.test.assertTrue(Array.isArray(obj.arr), "array part present"); + browser.test.assertEq(1, obj.arr[0], "arr[0] part correct"); + browser.test.assertEq(2, obj.arr[1], "arr[1] part correct"); + browser.test.assertEq(2, obj.arr.length, "arr.length part correct"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("storage"); + } + } + + browser.test.onMessage.addListener(msg => { + let promise; + if (msg === "test-local") { + promise = runTests("local"); + } else if (msg === "test-sync") { + promise = runTests("sync"); + } else if (msg === "test-session") { + promise = runTests("session"); + } + promise.then(() => browser.test.sendMessage("test-finished")); + }); + + browser.test.sendMessage("ready"); +} + +async function test_contentscript_storage(storageType) { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + + permissions: ["storage"], + }, + + files: { + "content_script.js": `(${testStorageContentScript})(${checkGetImpl})`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage(`test-${storageType}`); + await extension.awaitMessage("test-finished"); + + await extension.unload(); + await contentPage.close(); +} + +async function test_storage_empty_events(areaName) { + async function background(areaName) { + let eventCount = 0; + + browser.storage[areaName].onChanged.addListener(changes => { + browser.test.sendMessage("onChanged", [++eventCount, changes]); + }); + + browser.test.onMessage.addListener(async (method, arg) => { + let result = await browser.storage[areaName][method](arg); + browser.test.sendMessage("result", result); + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["storage"] }, + background: `(${background})("${areaName}")`, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + async function callStorageMethod(method, arg) { + info(`call storage.${areaName}.${method}(${JSON.stringify(arg) ?? ""})`); + extension.sendMessage(method, arg); + await extension.awaitMessage("result"); + } + + async function expectEvent(expectCount, expectChanges) { + equal( + JSON.stringify([expectCount, expectChanges]), + JSON.stringify(await extension.awaitMessage("onChanged")), + "Correct onChanged events count and data in the last changes notified." + ); + } + + await callStorageMethod("set", { alpha: 1 }); + await expectEvent(1, { alpha: { newValue: 1 } }); + + await callStorageMethod("set", {}); + // Setting nothing doesn't trigger onChanged event. + + await callStorageMethod("set", { beta: 12 }); + await expectEvent(2, { beta: { newValue: 12 } }); + + await callStorageMethod("remove", "alpha"); + await expectEvent(3, { alpha: { oldValue: 1 } }); + + await callStorageMethod("remove", "alpha"); + // Trying to remove alpha again doesn't trigger onChanged. + + await callStorageMethod("clear"); + await expectEvent(4, { beta: { oldValue: 12 } }); + + await callStorageMethod("clear"); + // Clear again wothout onChanged. Test will fail on unexpected event/message. + + await extension.unload(); +} + +async function test_storage_change_event_page(areaName) { + async function testOnChanged(targetIsStorageArea) { + function backgroundTestStorageTopNamespace(areaName) { + browser.storage.onChanged.addListener((changes, area) => { + browser.test.assertEq(area, areaName, "Expected areaName"); + browser.test.assertEq( + JSON.stringify(changes), + `{"storageKey":{"newValue":"newStorageValue"}}`, + "Expected changes" + ); + browser.test.sendMessage("onChanged_was_fired"); + }); + } + function backgroundTestStorageAreaNamespace(areaName) { + browser.storage[areaName].onChanged.addListener((changes, ...args) => { + browser.test.assertEq(args.length, 0, "no more args after changes"); + browser.test.assertEq( + JSON.stringify(changes), + `{"storageKey":{"newValue":"newStorageValue"}}`, + `Expected changes via ${areaName}.onChanged event` + ); + browser.test.sendMessage("onChanged_was_fired"); + }); + } + let background, onChangedName; + if (targetIsStorageArea) { + // Test storage.local.onChanged / storage.sync.onChanged. + background = backgroundTestStorageAreaNamespace; + onChangedName = `${areaName}.onChanged`; + } else { + background = backgroundTestStorageTopNamespace; + onChangedName = "onChanged"; + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + background: { persistent: false }, + }, + background: `(${background})("${areaName}")`, + files: { + "trigger-change.html": ` + + + `, + "trigger-change.js": async () => { + let areaName = location.search.slice(1); + await browser.storage[areaName].set({ + storageKey: "newStorageValue", + }); + browser.test.sendMessage("tried_to_trigger_change"); + }, + }, + }); + await extension.startup(); + assertPersistentListeners(extension, "storage", onChangedName, { + primed: false, + }); + + await extension.terminateBackground(); + assertPersistentListeners(extension, "storage", onChangedName, { + primed: true, + }); + + // Now trigger the event + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/trigger-change.html?${areaName}` + ); + await extension.awaitMessage("tried_to_trigger_change"); + await contentPage.close(); + await extension.awaitMessage("onChanged_was_fired"); + + assertPersistentListeners(extension, "storage", onChangedName, { + primed: false, + }); + await extension.unload(); + } + + async function testFn() { + // Test browser.storage.onChanged.addListener + await testOnChanged(/* targetIsStorageArea */ false); + // Test browser.storage.local.onChanged.addListener + // and browser.storage.sync.onChanged.addListener, depending on areaName. + await testOnChanged(/* targetIsStorageArea */ true); + } + + return runWithPrefs([["extensions.eventPages.enabled", true]], testFn); +} diff --git a/toolkit/components/extensions/test/xpcshell/head_sync.js b/toolkit/components/extensions/test/xpcshell/head_sync.js new file mode 100644 index 0000000000..7c88e23a86 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_sync.js @@ -0,0 +1,66 @@ +/* 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"; + +/* exported withSyncContext */ + +const { ExtensionCommon } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionCommon.sys.mjs" +); + +class KintoExtContext extends ExtensionCommon.BaseContext { + constructor(principal) { + let fakeExtension = { id: "test@web.extension", manifestVersion: 2 }; + super("addon_parent", fakeExtension); + Object.defineProperty(this, "principal", { + value: principal, + configurable: true, + }); + this.sandbox = Cu.Sandbox(principal, { wantXrays: false }); + } + + get cloneScope() { + return this.sandbox; + } +} + +/** + * Call the given function with a newly-constructed context. + * Unload the context on the way out. + * + * @param {Function} f the function to call + */ +async function withContext(f) { + const ssm = Services.scriptSecurityManager; + const PRINCIPAL1 = ssm.createContentPrincipalFromOrigin( + "http://www.example.org" + ); + const context = new KintoExtContext(PRINCIPAL1); + try { + await f(context); + } finally { + await context.unload(); + } +} + +/** + * Like withContext(), but also turn on the "storage.sync" pref for + * the duration of the function. + * Calls to this function can be replaced with calls to withContext + * once the pref becomes on by default. + * + * @param {Function} f the function to call + */ +async function withSyncContext(f) { + const STORAGE_SYNC_PREF = "webextensions.storage.sync.enabled"; + let prefs = Services.prefs; + + try { + prefs.setBoolPref(STORAGE_SYNC_PREF, true); + await withContext(f); + } finally { + prefs.clearUserPref(STORAGE_SYNC_PREF); + } +} diff --git a/toolkit/components/extensions/test/xpcshell/head_telemetry.js b/toolkit/components/extensions/test/xpcshell/head_telemetry.js new file mode 100644 index 0000000000..a05211d006 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_telemetry.js @@ -0,0 +1,594 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported IS_OOP, valueSum, clearHistograms, getSnapshots, promiseTelemetryRecorded, + assertDNRTelemetryMetricsDefined, assertDNRTelemetryMetricsNoSamples, assertDNRTelemetryMetricsGetValueEq, + assertDNRTelemetryMetricsSamplesCount, resetTelemetryData, setupTelemetryForTests */ + +ChromeUtils.defineESModuleGetters(this, { + ContentTaskUtils: "resource://testing-common/ContentTaskUtils.sys.mjs", +}); + +// Allows to run xpcshell telemetry test also on products (e.g. Thunderbird) where +// that telemetry wouldn't be actually collected in practice (but to be sure +// that it will work on those products as well by just adding the product in +// the telemetry metric definitions if it turns out we want to). +Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true +); + +const IS_OOP = Services.prefs.getBoolPref("extensions.webextensions.remote"); + +const WEBEXT_EVENTPAGE_RUNNING_TIME_MS = "WEBEXT_EVENTPAGE_RUNNING_TIME_MS"; +const WEBEXT_EVENTPAGE_RUNNING_TIME_MS_BY_ADDONID = + "WEBEXT_EVENTPAGE_RUNNING_TIME_MS_BY_ADDONID"; +const WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT = "WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT"; +const WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID = + "WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID"; + +// Keep this in sync with the order in Histograms.json for "WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT": +// the position of the category string determines the index of the values collected in the categorial +// histogram and so the existing labels should be kept in the exact same order and any new category +// to be added in the future should be appended to the existing ones. +const HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES = [ + "suspend", + "reset_other", + "reset_event", + "reset_listeners", + "reset_nativeapp", + "reset_streamfilter", + "reset_parentapicall", +]; + +const GLEAN_EVENTPAGE_IDLE_RESULT_CATEGORIES = [ + ...HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + "__other__", +]; + +function valueSum(arr) { + return Object.values(arr).reduce((a, b) => a + b, 0); +} + +function clearHistograms() { + Services.telemetry.getSnapshotForHistograms("main", true /* clear */); + Services.telemetry.getSnapshotForKeyedHistograms("main", true /* clear */); +} + +function clearScalars() { + Services.telemetry.getSnapshotForScalars("main", true /* clear */); + Services.telemetry.getSnapshotForKeyedScalars("main", true /* clear */); +} + +function getSnapshots(process) { + return Services.telemetry.getSnapshotForHistograms("main", false /* clear */)[ + process + ]; +} + +function getKeyedSnapshots(process) { + return Services.telemetry.getSnapshotForKeyedHistograms( + "main", + false /* clear */ + )[process]; +} + +// TODO Bug 1357509: There is no good way to make sure that the parent received +// the histogram entries from the extension and content processes. Let's stick +// to the ugly, spinning the event loop until we have a good approach. +function promiseTelemetryRecorded(id, process, expectedCount) { + let condition = () => { + let snapshot = Services.telemetry.getSnapshotForHistograms( + "main", + false /* clear */ + )[process][id]; + return snapshot && valueSum(snapshot.values) >= expectedCount; + }; + return ContentTaskUtils.waitForCondition(condition); +} + +function promiseKeyedTelemetryRecorded( + id, + process, + expectedKey, + expectedCount +) { + let condition = () => { + let snapshot = Services.telemetry.getSnapshotForKeyedHistograms( + "main", + false /* clear */ + )[process][id]; + return ( + snapshot && + snapshot[expectedKey] && + valueSum(snapshot[expectedKey].values) >= expectedCount + ); + }; + return ContentTaskUtils.waitForCondition(condition); +} + +function assertHistogramSnapshot( + histogramId, + { keyed, processSnapshot, expectedValue }, + msg +) { + let histogram; + + if (keyed) { + histogram = Services.telemetry.getKeyedHistogramById(histogramId); + } else { + histogram = Services.telemetry.getHistogramById(histogramId); + } + + let res = processSnapshot(histogram.snapshot()); + Assert.deepEqual(res, expectedValue, msg); + return res; +} + +function assertHistogramEmpty(histogramId) { + assertHistogramSnapshot( + histogramId, + { + processSnapshot: snapshot => snapshot.sum, + expectedValue: 0, + }, + `No data recorded for histogram: ${histogramId}.` + ); +} + +function assertKeyedHistogramEmpty(histogramId) { + assertHistogramSnapshot( + histogramId, + { + keyed: true, + processSnapshot: snapshot => Object.keys(snapshot).length, + expectedValue: 0, + }, + `No data recorded for histogram: ${histogramId}.` + ); +} + +function assertHistogramCategoryNotEmpty( + histogramId, + { category, categories, keyed, key }, + msg +) { + let message = msg; + + if (!msg) { + message = `Data recorded for histogram: ${histogramId}, category "${category}"`; + if (keyed) { + message += `, key "${key}"`; + } + } + + assertHistogramSnapshot( + histogramId, + { + keyed, + processSnapshot: snapshot => { + const categoryIndex = categories.indexOf(category); + if (keyed) { + return { + [key]: snapshot[key] + ? snapshot[key].values[categoryIndex] > 0 + : null, + }; + } + return snapshot.values[categoryIndex] > 0; + }, + expectedValue: keyed ? { [key]: true } : true, + }, + message + ); +} + +function setupTelemetryForTests() { + // FOG needs a profile directory to put its data in. + do_get_profile(); + // FOG needs to be initialized in order for data to flow. + Services.fog.initializeFOG(); +} + +function resetTelemetryData() { + Services.fog.testResetFOG(); + + // Clear histograms data recorded in the unified telemetry + // (needed to make sure we can keep asserting that the same + // amount of samples collected by Glean should also be found + // in the related mirrored unified telemetry probe after we + // have reset Glean metrics data using testResetFOG). + clearHistograms(); + clearScalars(); +} + +function assertValidGleanMetric({ + metricId, + gleanMetric, + gleanMetricConstructor, + msg, +}) { + const { GleanMetric } = globalThis; + if (!(gleanMetric instanceof GleanMetric)) { + throw new Error( + `gleanMetric "${metricId}" ${gleanMetric} should be an instance of GleanMetric ${msg}` + ); + } + + if ( + gleanMetricConstructor && + !(gleanMetric instanceof gleanMetricConstructor) + ) { + throw new Error( + `gleanMetric "${metricId}" should be an instance of the given GleanMetric constructor: ${gleanMetric} not an instance of ${gleanMetricConstructor} ${msg}` + ); + } +} + +// TODO reuse this helper inside the DNR specific test helper which would be doing +// a similar assertion on DNR metrics. +function assertGleanMetricsNoSamples({ + metricId, + gleanMetric, + gleanMetricConstructor, + message, +}) { + const msg = message ? `(${message})` : ""; + assertValidGleanMetric({ + metricId, + gleanMetric, + gleanMetricConstructor, + msg, + }); + const gleanData = gleanMetric.testGetValue(); + Assert.deepEqual( + gleanData, + undefined, + `Got no sample for Glean metric ${metricId} ${msg}` + ); +} + +// TODO reuse this helper inside the DNR specific test helper which would be doing +// a similar assertion on DNR metrics. +function assertGleanMetricsSamplesCount({ + metricId, + gleanMetric, + gleanMetricConstructor, + expectedSamplesCount, + message, +}) { + const msg = message ? `(${message})` : ""; + assertValidGleanMetric({ + metricId, + gleanMetric, + gleanMetricConstructor, + msg, + }); + const gleanData = gleanMetric.testGetValue(); + Assert.notEqual( + gleanData, + undefined, + `Got some sample for Glean metric ${metricId} ${msg}` + ); + Assert.equal( + valueSum(gleanData.values), + expectedSamplesCount, + `Got the expected number of samples for Glean metric ${metricId} ${msg}` + ); +} + +function assertGleanLabeledCounter({ + metricId, + gleanMetric, + gleanMetricLabels, + expectedLabelsValue, + ignoreNonExpectedLabels, + ignoreUnknownLabels, + message, +}) { + const { GleanLabeled } = globalThis; + const msg = message ? `(${message})` : ""; + if (!Array.isArray(gleanMetricLabels) || !gleanMetricLabels.length) { + throw new Error( + `Missing mandatory gleanMetricLabels property ${msg}: ${gleanMetricLabels}` + ); + } + + if (!(gleanMetric instanceof GleanLabeled)) { + throw new Error( + `Glean metric "${metricId}" should be an instance of GleanLabeled: ${gleanMetric} ${msg}` + ); + } + + for (const label of gleanMetricLabels) { + const expectedLabelValue = expectedLabelsValue[label]; + if (ignoreNonExpectedLabels && !(label in expectedLabelsValue)) { + continue; + } + Assert.deepEqual( + gleanMetric[label].testGetValue(), + expectedLabelValue, + `Expect Glean "${metricId}" metric label "${label}" to be ${ + expectedLabelValue > 0 ? expectedLabelValue : "empty" + }` + ); + } + + if (!ignoreUnknownLabels) { + Assert.deepEqual( + gleanMetric["__other__"].testGetValue(), // eslint-disable-line dot-notation + undefined, + `Expect Glean "${metricId}" metric label "__other__" to be empty.` + ); + } +} + +function assertGleanLabeledCounterEmpty({ + metricId, + gleanMetric, + gleanMetricLabels, + message, +}) { + // All empty labels passed to the other helpers to make it + // assert that all labels are empty. + assertGleanLabeledCounter({ + metricId, + gleanMetric, + gleanMetricLabels, + expectedLabelsValue: {}, + message, + }); +} + +function assertGleanLabeledCounterNotEmpty({ + metricId, + gleanMetric, + expectedNotEmptyLabels, + ignoreUnknownLabels, + message, +}) { + const { GleanLabeled } = globalThis; + const msg = message ? `(${message})` : ""; + if ( + !Array.isArray(expectedNotEmptyLabels) || + !expectedNotEmptyLabels.length + ) { + throw new Error( + `Missing mandatory expectedNotEmptyLabels property ${msg}: ${expectedNotEmptyLabels}` + ); + } + + if (!(gleanMetric instanceof GleanLabeled)) { + throw new Error( + `Glean metric "${metricId}" should be an instance of GleanLabeled: ${gleanMetric} ${msg}` + ); + } + + for (const label of expectedNotEmptyLabels) { + Assert.notEqual( + gleanMetric[label].testGetValue(), + undefined, + `Expect Glean "${metricId}" metric label "${label}" to not be empty` + ); + } + + if (!ignoreUnknownLabels) { + Assert.deepEqual( + gleanMetric["__other__"].testGetValue(), // eslint-disable-line dot-notation + undefined, + `Expect Glean "${metricId}" metric label "__other__" to be empty.` + ); + } +} + +function assertDNRTelemetryMetricsDefined(metrics) { + const metricsNotFound = metrics.filter(metricDetails => { + const { metric, label } = metricDetails; + if (!Glean.extensionsApisDnr[metric]) { + return true; + } + if (label) { + return !Glean.extensionsApisDnr[metric][label]; + } + return false; + }); + Assert.deepEqual( + metricsNotFound, + [], + `All expected extensionsApisDnr Glean metrics should be found` + ); +} + +function assertDNRTelemetryMirrored({ + gleanMetric, + gleanLabel, + unifiedName, + unifiedType, +}) { + assertDNRTelemetryMetricsDefined([ + { metric: gleanMetric, label: gleanLabel }, + ]); + const gleanData = gleanLabel + ? Glean.extensionsApisDnr[gleanMetric][gleanLabel].testGetValue() + : Glean.extensionsApisDnr[gleanMetric].testGetValue(); + + if (!unifiedName) { + Assert.ok( + false, + `Unexpected missing unifiedName parameter on call to assertDNRTelemetryMirrored` + ); + return; + } + + let unifiedData; + + switch (unifiedType) { + case "histogram": { + let found = false; + try { + const histogram = Services.telemetry.getHistogramById(unifiedName); + found = !!histogram; + } catch (err) { + Cu.reportError(err); + } + Assert.ok(found, `Expect an histogram named ${unifiedName} to be found`); + unifiedData = Services.telemetry.getSnapshotForHistograms("main", false) + .parent[unifiedName]; + break; + } + case "keyedScalar": { + const snapshot = Services.telemetry.getSnapshotForKeyedScalars( + "main", + false + ); + if (unifiedName in (snapshot?.parent || {})) { + unifiedData = snapshot.parent[unifiedName][gleanLabel]; + } + break; + } + case "scalar": { + const snapshot = Services.telemetry.getSnapshotForScalars("main", false); + if (unifiedName in (snapshot?.parent || {})) { + unifiedData = snapshot.parent[unifiedName]; + } + break; + } + default: + Assert.ok( + false, + `Unexpected unifiedType ${unifiedType} on call to assertDNRTelemetryMirrored` + ); + return; + } + + if (gleanData == undefined) { + Assert.deepEqual( + unifiedData, + undefined, + `Expect mirrored unified telemetry ${unifiedType} ${unifiedName} has no samples as Glean ${gleanMetric}` + ); + } else { + switch (unifiedType) { + case "histogram": { + Assert.deepEqual( + valueSum(unifiedData.values), + valueSum(gleanData.values), + `Expect mirrored unified telemetry ${unifiedType} ${unifiedName} has samples mirrored from Glean ${gleanMetric}` + ); + break; + } + case "scalar": + case "keyedScalar": { + Assert.deepEqual( + unifiedData, + gleanData, + `Expect mirrored unified telemetry ${unifiedType} ${unifiedName} has samples mirrored from Glean ${gleanMetric}` + ); + break; + } + } + } +} + +function assertDNRTelemetryMetricsNoSamples(metrics, msg) { + assertDNRTelemetryMetricsDefined(metrics); + for (const metricDetails of metrics) { + const { metric, label } = metricDetails; + + const gleanData = label + ? Glean.extensionsApisDnr[metric][label].testGetValue() + : Glean.extensionsApisDnr[metric].testGetValue(); + Assert.deepEqual( + gleanData, + undefined, + `Expect no sample for Glean metric extensionApisDnr.${metric} (${msg}): ${gleanData}` + ); + + if (metricDetails.mirroredName) { + const { mirroredName, mirroredType } = metricDetails; + assertDNRTelemetryMirrored({ + gleanMetric: metric, + gleanLabel: label, + unifiedName: mirroredName, + unifiedType: mirroredType, + }); + } + } +} + +function assertDNRTelemetryMetricsGetValueEq(metrics, msg) { + assertDNRTelemetryMetricsDefined(metrics); + for (const metricDetails of metrics) { + const { metric, label, expectedGetValue } = metricDetails; + + const gleanData = label + ? Glean.extensionsApisDnr[metric][label].testGetValue() + : Glean.extensionsApisDnr[metric].testGetValue(); + Assert.deepEqual( + gleanData, + expectedGetValue, + `Got expected value set on Glean metric extensionApisDnr.${metric}${ + label ? `.${label}` : "" + } (${msg})` + ); + + if (metricDetails.mirroredName) { + const { mirroredName, mirroredType } = metricDetails; + assertDNRTelemetryMirrored({ + gleanMetric: metric, + gleanLabel: label, + unifiedName: mirroredName, + unifiedType: mirroredType, + }); + } + } +} + +function assertDNRTelemetryMetricsSamplesCount(metrics, msg) { + assertDNRTelemetryMetricsDefined(metrics); + + // This assertion helpers doesn't currently handle labeled metrics, + // raise an explicit error to catch if one is included by mistake. + const labeledMetricsFound = metrics.filter(metric => !!metric.label); + if (labeledMetricsFound.length) { + throw new Error( + `Unexpected labeled metrics in call to assertDNRTelemetryMetricsSamplesCount: ${labeledMetricsFound}` + ); + } + + for (const metricDetails of metrics) { + const { metric, expectedSamplesCount } = metricDetails; + + const gleanData = Glean.extensionsApisDnr[metric].testGetValue(); + Assert.notEqual( + gleanData, + undefined, + `Got some sample for Glean metric extensionApisDnr.${metric}: ${ + gleanData && JSON.stringify(gleanData) + }` + ); + Assert.equal( + valueSum(gleanData.values), + expectedSamplesCount, + `Got the expected number of samples for Glean metric extensionsApisDnr.${metric} (${msg})` + ); + // Make sure we are accumulating meaningfull values in the sample, + // if we do have samples for the bucket "0" it likely means we have + // not been collecting the value correctly (e.g. typo in the property + // name being collected). + Assert.ok( + !gleanData.values["0"], + `No sample for Glean metric extensionsApisDnr.${metric} should be collected for the bucket "0"` + ); + + if (metricDetails.mirroredName) { + const { mirroredName, mirroredType } = metricDetails; + assertDNRTelemetryMirrored({ + gleanMetric: metric, + unifiedName: mirroredName, + unifiedType: mirroredType, + }); + } + } +} diff --git a/toolkit/components/extensions/test/xpcshell/native_messaging.toml b/toolkit/components/extensions/test/xpcshell/native_messaging.toml new file mode 100644 index 0000000000..468636fcc6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/native_messaging.toml @@ -0,0 +1,19 @@ +[DEFAULT] +head = "head.js head_native_messaging.js head_telemetry.js" +firefox-appdir = "browser" +skip-if = [ + "appname == 'thunderbird'", + "os == 'android'", +] +subprocess = true +support-files = ["data/**"] +tags = "webextensions" + +["test_ext_native_messaging.js"] +skip-if = ["apple_silicon"] # bug 1729540 +run-sequentially = "very high failure rate in parallel" + +["test_ext_native_messaging_perf.js"] +skip-if = ["tsan"] # Unreasonably slow, bug 1612707 + +["test_ext_native_messaging_unresponsive.js"] diff --git a/toolkit/components/extensions/test/xpcshell/test_ExtensionShortcutKeyMap.js b/toolkit/components/extensions/test/xpcshell/test_ExtensionShortcutKeyMap.js new file mode 100644 index 0000000000..8e48684095 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ExtensionShortcutKeyMap.js @@ -0,0 +1,141 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionShortcutKeyMap } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionShortcuts.sys.mjs" +); + +add_task(function test_ExtensionShortcutKeymap() { + const shortcutsMap = new ExtensionShortcutKeyMap(); + + shortcutsMap.recordShortcut("Ctrl+Shift+1", "Addon1", "Command1"); + shortcutsMap.recordShortcut("Ctrl+Shift+1", "Addon2", "Command2"); + shortcutsMap.recordShortcut("Ctrl+Alt+2", "Addon2", "Command3"); + // Empty shortcut not expected to be recorded, just ignored. + shortcutsMap.recordShortcut("", "Addon3", "Command4"); + + Assert.equal( + shortcutsMap.size, + 2, + "Got the expected number of shortcut entries" + ); + Assert.deepEqual( + { + shortcutWithTwoExtensions: shortcutsMap.getFirstAddonName("Ctrl+Shift+1"), + shortcutWithOnlyOneExtension: + shortcutsMap.getFirstAddonName("Ctrl+Alt+2"), + shortcutWithNoExtension: shortcutsMap.getFirstAddonName(""), + }, + { + shortcutWithTwoExtensions: "Addon1", + shortcutWithOnlyOneExtension: "Addon2", + shortcutWithNoExtension: null, + }, + "Got the expected results from getFirstAddonName calls" + ); + + Assert.deepEqual( + { + shortcutWithTwoExtensions: shortcutsMap.has("Ctrl+Shift+1"), + shortcutWithOnlyOneExtension: shortcutsMap.has("Ctrl+Alt+2"), + shortcutWithNoExtension: shortcutsMap.has(""), + }, + { + shortcutWithTwoExtensions: true, + shortcutWithOnlyOneExtension: true, + shortcutWithNoExtension: false, + }, + "Got the expected results from `has` calls" + ); + + shortcutsMap.removeShortcut("Ctrl+Shift+1", "Addon1", "Command1"); + Assert.equal( + shortcutsMap.has("Ctrl+Shift+1"), + true, + "Expect shortcut to already exist after removing one duplicate" + ); + Assert.equal( + shortcutsMap.getFirstAddonName("Ctrl+Shift+1"), + "Addon2", + "Expect getFirstAddonName to return the remaining addon name" + ); + + shortcutsMap.removeShortcut("Ctrl+Shift+1", "Addon2", "Command2"); + Assert.equal( + shortcutsMap.has("Ctrl+Shift+1"), + false, + "Expect shortcut to not exist anymore after removing last entry" + ); + Assert.equal(shortcutsMap.size, 1, "Got only one shortcut as expected"); + + shortcutsMap.clear(); + Assert.equal( + shortcutsMap.size, + 0, + "Got no shortcut as expected after clearing the map" + ); +}); + +// This test verify that ExtensionShortcutKeyMap does catch duplicated +// shortcut when the two modifiers strings are associated to the same +// key (in particular on macOS where Ctrl and Command keys are both translated +// in the same modifier in the keyboard shortcuts). +add_task(function test_PlatformShortcutString() { + const shortcutsMap = new ExtensionShortcutKeyMap(); + + // Make the class instance behave like it would while running on macOS. + // (this is just for unit testing purpose, there is a separate integration + // test exercising this behavior in a real "Manage Extension Shortcut" + // about:addons view and only running on macOS, skipped on other platforms). + shortcutsMap._os = "mac"; + + shortcutsMap.recordShortcut("Ctrl+Shift+1", "Addon1", "MacCommand1"); + + Assert.deepEqual( + { + hasWithCtrl: shortcutsMap.has("Ctrl+Shift+1"), + hasWithCommand: shortcutsMap.has("Command+Shift+1"), + }, + { + hasWithCtrl: true, + hasWithCommand: true, + }, + "Got the expected results from `has` calls" + ); + + Assert.deepEqual( + { + nameWithCtrl: shortcutsMap.getFirstAddonName("Ctrl+Shift+1"), + nameWithCommand: shortcutsMap.getFirstAddonName("Command+Shift+1"), + }, + { + nameWithCtrl: "Addon1", + nameWithCommand: "Addon1", + }, + "Got the expected results from `getFirstAddonName` calls" + ); + + // Add a duplicate shortcut using Command instead of Ctrl and + // verify the expected behaviors. + shortcutsMap.recordShortcut("Command+Shift+1", "Addon2", "MacCommand2"); + Assert.equal(shortcutsMap.size, 1, "Got still one shortcut as expected"); + shortcutsMap.removeShortcut("Ctrl+Shift+1", "Addon1", "MacCommand1"); + Assert.equal(shortcutsMap.size, 1, "Got still one shortcut as expected"); + Assert.deepEqual( + { + nameWithCtrl: shortcutsMap.getFirstAddonName("Ctrl+Shift+1"), + nameWithCommand: shortcutsMap.getFirstAddonName("Command+Shift+1"), + }, + { + nameWithCtrl: "Addon2", + nameWithCommand: "Addon2", + }, + "Got the expected results from `getFirstAddonName` calls" + ); + + // Remove the entry added with a shortcut using "Command" by using the + // equivalent shortcut using Ctrl. + shortcutsMap.removeShortcut("Ctrl+Shift+1", "Addon2", "MacCommand2"); + Assert.equal(shortcutsMap.size, 0, "Got no shortcut as expected"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ExtensionStorageSync_migration_kinto.js b/toolkit/components/extensions/test/xpcshell/test_ExtensionStorageSync_migration_kinto.js new file mode 100644 index 0000000000..7cde68ee98 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ExtensionStorageSync_migration_kinto.js @@ -0,0 +1,86 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Import the rust-based and kinto-based implementations +const { extensionStorageSync: rustImpl } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageSync.sys.mjs" +); +const { extensionStorageSyncKinto: kintoImpl } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageSyncKinto.sys.mjs" +); + +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", false); + +add_task(async function test_sync_migration() { + // There's no good reason to perform this test via test extensions - we just + // call the underlying APIs directly. + + // Set some stuff using the kinto-based impl. + let e1 = { id: "test@mozilla.com" }; + let c1 = { extension: e1, callOnClose() {} }; + await kintoImpl.set(e1, { foo: "bar" }, c1); + + let e2 = { id: "test-2@mozilla.com" }; + let c2 = { extension: e2, callOnClose() {} }; + await kintoImpl.set(e2, { second: "2nd" }, c2); + + let e3 = { id: "test-3@mozilla.com" }; + let c3 = { extension: e3, callOnClose() {} }; + + // And all the data should be magically migrated. + Assert.deepEqual(await rustImpl.get(e1, "foo", c1), { foo: "bar" }); + Assert.deepEqual(await rustImpl.get(e2, null, c2), { second: "2nd" }); + + // Sanity check we really are doing what we think we are - set a value in our + // new one, it should not be reflected by kinto. + await rustImpl.set(e3, { third: "3rd" }, c3); + Assert.deepEqual(await rustImpl.get(e3, null, c3), { third: "3rd" }); + Assert.deepEqual(await kintoImpl.get(e3, null, c3), {}); + // cleanup. + await kintoImpl.clear(e1, c1); + await kintoImpl.clear(e2, c2); + await kintoImpl.clear(e3, c3); + await rustImpl.clear(e1, c1); + await rustImpl.clear(e2, c2); + await rustImpl.clear(e3, c3); +}); + +// It would be great to have failure tests, but that seems impossible to have +// in automated tests given the conditions under which we migrate - it would +// basically require us to arrange for zero free disk space or to somehow +// arrange for sqlite to see an io error. Specially crafted "corrupt" +// sqlite files doesn't help because that file must not exist for us to even +// attempt migration. +// +// But - what we can test is that if .migratedOk on the new impl ever goes to +// false we delegate correctly. +add_task(async function test_sync_migration_delgates() { + let e1 = { id: "test@mozilla.com" }; + let c1 = { extension: e1, callOnClose() {} }; + await kintoImpl.set(e1, { foo: "bar" }, c1); + + // We think migration went OK - `get` shouldn't see kinto. + Assert.deepEqual(rustImpl.get(e1, null, c1), {}); + + info( + "Setting migration failure flag to ensure we delegate to kinto implementation" + ); + rustImpl.migrationOk = false; + // get should now be seeing kinto. + Assert.deepEqual(await rustImpl.get(e1, null, c1), { foo: "bar" }); + // check everything else delegates. + + await rustImpl.set(e1, { foo: "foo" }, c1); + Assert.deepEqual(await kintoImpl.get(e1, null, c1), { foo: "foo" }); + + Assert.equal(await rustImpl.getBytesInUse(e1, null, c1), 8); + + await rustImpl.remove(e1, "foo", c1); + Assert.deepEqual(await kintoImpl.get(e1, null, c1), {}); + + await rustImpl.set(e1, { foo: "foo" }, c1); + Assert.deepEqual(await kintoImpl.get(e1, null, c1), { foo: "foo" }); + await rustImpl.clear(e1, c1); + Assert.deepEqual(await kintoImpl.get(e1, null, c1), {}); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_MatchPattern.js b/toolkit/components/extensions/test/xpcshell/test_MatchPattern.js new file mode 100644 index 0000000000..4d7da529e3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_MatchPattern.js @@ -0,0 +1,685 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +registerCleanupFunction(async () => { + Services.prefs.clearUserPref("network.url.useDefaultURI"); +}); + +add_setup(async function () { + // unknown-scheme://foo tests will fail with default URI + // see bug 1868413 (to re-enable) + Services.prefs.setBoolPref("network.url.useDefaultURI", false); +}); + +add_task(async function test_MatchPattern_matches() { + function test(url, pattern, normalized = pattern, options = {}, explicit) { + let uri = Services.io.newURI(url); + + pattern = Array.prototype.concat.call(pattern); + normalized = Array.prototype.concat.call(normalized); + + let patterns = pattern.map(pat => new MatchPattern(pat, options)); + + let set = new MatchPatternSet(pattern, options); + let set2 = new MatchPatternSet(patterns, options); + + deepEqual( + set2.patterns, + patterns, + "Patterns in set should equal the input patterns" + ); + + equal( + set.matches(uri, explicit), + set2.matches(uri, explicit), + "Single pattern and pattern set should return the same match" + ); + + for (let [i, pat] of patterns.entries()) { + equal( + pat.pattern, + normalized[i], + "Pattern property should contain correct normalized pattern value" + ); + } + + if (patterns.length == 1) { + equal( + patterns[0].matches(uri, explicit), + set.matches(uri, explicit), + "Single pattern and string set should return the same match" + ); + } + + return set.matches(uri, explicit); + } + + function pass({ url, pattern, normalized, options, explicit }) { + ok( + test(url, pattern, normalized, options, explicit), + `Expected match: ${JSON.stringify(pattern)}, ${url}` + ); + } + + function fail({ url, pattern, normalized, options, explicit }) { + ok( + !test(url, pattern, normalized, options, explicit), + `Expected no match: ${JSON.stringify(pattern)}, ${url}` + ); + } + + function invalid({ pattern }) { + Assert.throws( + () => new MatchPattern(pattern), + /.*/, + `Invalid pattern '${pattern}' should throw` + ); + Assert.throws( + () => new MatchPatternSet([pattern]), + /.*/, + `Invalid pattern '${pattern}' should throw` + ); + } + + // Invalid pattern. + invalid({ pattern: "" }); + + // Pattern must include trailing slash. + invalid({ pattern: "http://mozilla.org" }); + + // Protocol not allowed. + invalid({ pattern: "gopher://wuarchive.wustl.edu/" }); + + pass({ url: "http://mozilla.org", pattern: "http://mozilla.org/" }); + pass({ url: "http://mozilla.org/", pattern: "http://mozilla.org/" }); + + pass({ url: "http://mozilla.org/", pattern: "*://mozilla.org/" }); + pass({ url: "https://mozilla.org/", pattern: "*://mozilla.org/" }); + fail({ url: "file://mozilla.org/", pattern: "*://mozilla.org/" }); + fail({ url: "ftp://mozilla.org/", pattern: "*://mozilla.org/" }); + + fail({ url: "http://mozilla.com", pattern: "http://*mozilla.com*/" }); + fail({ url: "http://mozilla.com", pattern: "http://mozilla.*/" }); + invalid({ pattern: "http:/mozilla.com/" }); + + pass({ url: "http://google.com", pattern: "http://*.google.com/" }); + pass({ url: "http://docs.google.com", pattern: "http://*.google.com/" }); + + pass({ url: "http://mozilla.org:8080", pattern: "http://mozilla.org/" }); + pass({ url: "http://mozilla.org:8080", pattern: "*://mozilla.org/" }); + fail({ url: "http://mozilla.org:8080", pattern: "http://mozilla.org:8080/" }); + + // Now try with * in the path. + pass({ url: "http://mozilla.org", pattern: "http://mozilla.org/*" }); + pass({ url: "http://mozilla.org/", pattern: "http://mozilla.org/*" }); + + pass({ url: "http://mozilla.org/", pattern: "*://mozilla.org/*" }); + pass({ url: "https://mozilla.org/", pattern: "*://mozilla.org/*" }); + fail({ url: "file://mozilla.org/", pattern: "*://mozilla.org/*" }); + fail({ url: "http://mozilla.com", pattern: "http://mozilla.*/*" }); + + pass({ url: "http://google.com", pattern: "http://*.google.com/*" }); + pass({ url: "http://docs.google.com", pattern: "http://*.google.com/*" }); + + // Check path stuff. + fail({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/" }); + pass({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*" }); + pass({ + url: "http://mozilla.com/abc/def", + pattern: "http://mozilla.com/a*f", + }); + pass({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/a*" }); + pass({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*f" }); + fail({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*e" }); + fail({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*c" }); + + invalid({ pattern: "http:///a.html" }); + pass({ url: "file:///foo", pattern: "file:///foo*" }); + pass({ url: "file:///foo/bar.html", pattern: "file:///foo*" }); + + pass({ url: "http://mozilla.org/a", pattern: "" }); + pass({ url: "https://mozilla.org/a", pattern: "" }); + pass({ url: "ftp://mozilla.org/a", pattern: "" }); + pass({ url: "file:///a", pattern: "" }); + fail({ url: "gopher://wuarchive.wustl.edu/a", pattern: "" }); + + // Multiple patterns. + pass({ url: "http://mozilla.org", pattern: ["http://mozilla.org/"] }); + pass({ + url: "http://mozilla.org", + pattern: ["http://mozilla.org/", "http://mozilla.com/"], + }); + pass({ + url: "http://mozilla.com", + pattern: ["http://mozilla.org/", "http://mozilla.com/"], + }); + fail({ + url: "http://mozilla.biz", + pattern: ["http://mozilla.org/", "http://mozilla.com/"], + }); + + // Match url with fragments. + pass({ + url: "http://mozilla.org/base#some-fragment", + pattern: "http://mozilla.org/base", + }); + + // Match data:-URLs. + pass({ url: "data:text/plain,foo", pattern: ["data:text/plain,foo"] }); + pass({ url: "data:text/plain,foo", pattern: ["data:text/plain,*"] }); + pass({ + url: "data:text/plain;charset=utf-8,foo", + pattern: ["data:text/plain;charset=utf-8,foo"], + }); + fail({ + url: "data:text/plain,foo", + pattern: ["data:text/plain;charset=utf-8,foo"], + }); + fail({ + url: "data:text/plain;charset=utf-8,foo", + pattern: ["data:text/plain,foo"], + }); + + // Privileged matchers: + invalid({ pattern: "about:foo" }); + invalid({ pattern: "resource://foo/*" }); + + pass({ + url: "about:foo", + pattern: ["about:foo", "about:foo*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "about:foo", + pattern: ["about:foo*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "about:foobar", + pattern: ["about:foo*"], + options: { restrictSchemes: false }, + }); + + pass({ + url: "resource://foo/bar", + pattern: ["resource://foo/bar"], + options: { restrictSchemes: false }, + }); + fail({ + url: "resource://fog/bar", + pattern: ["resource://foo/bar"], + options: { restrictSchemes: false }, + }); + fail({ + url: "about:foo", + pattern: ["about:meh"], + options: { restrictSchemes: false }, + }); + + // Matchers for schemes without host should ignore ignorePath. + pass({ + url: "about:reader?http://e.com/", + pattern: ["about:reader*"], + options: { ignorePath: true, restrictSchemes: false }, + }); + pass({ url: "data:,", pattern: ["data:,*"], options: { ignorePath: true } }); + + // Matchers for schems without host should still match even if the explicit (host) flag is set. + pass({ + url: "about:reader?explicit", + pattern: ["about:reader*"], + options: { restrictSchemes: false }, + explicit: true, + }); + pass({ + url: "about:reader?explicit", + pattern: ["about:reader?explicit"], + options: { restrictSchemes: false }, + explicit: true, + }); + pass({ url: "data:,explicit", pattern: ["data:,explicit"], explicit: true }); + pass({ url: "data:,explicit", pattern: ["data:,*"], explicit: true }); + + // Matchers without "//" separator in the pattern. + pass({ url: "data:text/plain;charset=utf-8,foo", pattern: ["data:*"] }); + pass({ + url: "about:blank", + pattern: ["about:*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "view-source:https://example.com", + pattern: ["view-source:*"], + options: { restrictSchemes: false }, + }); + invalid({ pattern: ["chrome:*"], options: { restrictSchemes: false } }); + invalid({ pattern: "http:*" }); + + // Matchers for unrecognized schemes. + invalid({ pattern: "unknown-scheme:*" }); + pass({ + url: "unknown-scheme:foo", + pattern: ["unknown-scheme:foo"], + options: { restrictSchemes: false }, + }); + pass({ + url: "unknown-scheme:foo", + pattern: ["unknown-scheme:*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "unknown-scheme://foo", + pattern: ["unknown-scheme://foo"], + options: { restrictSchemes: false }, + }); + pass({ + url: "unknown-scheme://foo", + pattern: ["unknown-scheme://*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "unknown-scheme://foo", + pattern: ["unknown-scheme:*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "unknown-scheme:/foo", + pattern: ["unknown-scheme:/*"], + options: { restrictSchemes: false }, + }); + fail({ + url: "unknown-scheme://foo", + pattern: ["unknown-scheme:foo"], + options: { restrictSchemes: false }, + }); + fail({ + url: "unknown-scheme:foo", + pattern: ["unknown-scheme://foo"], + options: { restrictSchemes: false }, + }); + fail({ + url: "unknown-scheme:foo", + pattern: ["unknown-scheme://*"], + options: { restrictSchemes: false }, + }); + fail({ + url: "unknown-scheme:foo", + pattern: ["unknown-scheme:/*"], + options: { restrictSchemes: false }, + }); + + // Matchers for IPv6 + pass({ url: "http://[::1]/", pattern: ["http://[::1]/"] }); + pass({ + url: "http://[2a03:4000:6:310e:216:3eff:fe53:99b]/", + pattern: ["http://[2a03:4000:6:310e:216:3eff:fe53:99b]/"], + }); + fail({ + url: "http://[2:4:6:3:2:3:f:b]/", + pattern: ["http://[2a03:4000:6:310e:216:3eff:fe53:99b]/"], + }); + + // Before fixing Bug 1529230, the only way to match a specific IPv6 url is by droping the brackets in pattern, + // thus we keep this pattern valid for the sake of backward compatibility + pass({ url: "http://[::1]/", pattern: ["http://::1/"] }); + pass({ + url: "http://[2a03:4000:6:310e:216:3eff:fe53:99b]/", + pattern: ["http://2a03:4000:6:310e:216:3eff:fe53:99b/"], + }); +}); + +add_task(async function test_MatchPattern_overlaps() { + function test(filter, hosts, optional) { + filter = Array.prototype.concat.call(filter); + hosts = Array.prototype.concat.call(hosts); + optional = Array.prototype.concat.call(optional); + + const set = new MatchPatternSet([...hosts, ...optional]); + const pat = new MatchPatternSet(filter); + return set.overlapsAll(pat); + } + + function pass({ filter = [], hosts = [], optional = [] }) { + ok( + test(filter, hosts, optional), + `Expected overlap: ${filter}, ${hosts} (${optional})` + ); + } + + function fail({ filter = [], hosts = [], optional = [] }) { + ok( + !test(filter, hosts, optional), + `Expected no overlap: ${filter}, ${hosts} (${optional})` + ); + } + + // Direct comparison. + pass({ hosts: "http://ab.cd/", filter: "http://ab.cd/" }); + fail({ hosts: "http://ab.cd/", filter: "ftp://ab.cd/" }); + + // Wildcard protocol. + pass({ hosts: "*://ab.cd/", filter: "https://ab.cd/" }); + fail({ hosts: "*://ab.cd/", filter: "ftp://ab.cd/" }); + + // Wildcard subdomain. + pass({ hosts: "http://*.ab.cd/", filter: "http://ab.cd/" }); + pass({ hosts: "http://*.ab.cd/", filter: "http://www.ab.cd/" }); + fail({ hosts: "http://*.ab.cd/", filter: "http://ab.cd.ef/" }); + fail({ hosts: "http://*.ab.cd/", filter: "http://www.cd/" }); + + // Wildcard subsumed. + pass({ hosts: "http://*.ab.cd/", filter: "http://*.cd/" }); + fail({ hosts: "http://*.cd/", filter: "http://*.xy/" }); + + // Subdomain vs substring. + fail({ hosts: "http://*.ab.cd/", filter: "http://fake-ab.cd/" }); + fail({ hosts: "http://*.ab.cd/", filter: "http://*.fake-ab.cd/" }); + + // Wildcard domain. + pass({ hosts: "http://*/", filter: "http://ab.cd/" }); + fail({ hosts: "http://*/", filter: "https://ab.cd/" }); + + // Wildcard wildcards. + pass({ hosts: "", filter: "ftp://ab.cd/" }); + fail({ hosts: "" }); + + // Multiple hosts. + pass({ hosts: ["http://ab.cd/"], filter: ["http://ab.cd/"] }); + pass({ hosts: ["http://ab.cd/", "http://ab.xy/"], filter: "http://ab.cd/" }); + pass({ hosts: ["http://ab.cd/", "http://ab.xy/"], filter: "http://ab.xy/" }); + fail({ hosts: ["http://ab.cd/", "http://ab.xy/"], filter: "http://ab.zz/" }); + + // Multiple Multiples. + pass({ + hosts: ["http://*.ab.cd/"], + filter: ["http://ab.cd/", "http://www.ab.cd/"], + }); + pass({ + hosts: ["http://ab.cd/", "http://ab.xy/"], + filter: ["http://ab.cd/", "http://ab.xy/"], + }); + fail({ + hosts: ["http://ab.cd/", "http://ab.xy/"], + filter: ["http://ab.cd/", "http://ab.zz/"], + }); + + // Optional. + pass({ hosts: [], optional: "http://ab.cd/", filter: "http://ab.cd/" }); + pass({ + hosts: "http://ab.cd/", + optional: "http://ab.xy/", + filter: ["http://ab.cd/", "http://ab.xy/"], + }); + fail({ + hosts: "http://ab.cd/", + optional: "https://ab.xy/", + filter: "http://ab.xy/", + }); +}); + +add_task(async function test_MatchGlob() { + function test(url, pattern) { + let m = new MatchGlob(pattern[0]); + return m.matches(Services.io.newURI(url).spec); + } + + function pass({ url, pattern }) { + ok( + test(url, pattern), + `Expected match: ${JSON.stringify(pattern)}, ${url}` + ); + } + + function fail({ url, pattern }) { + ok( + !test(url, pattern), + `Expected no match: ${JSON.stringify(pattern)}, ${url}` + ); + } + + let moz = "http://mozilla.org"; + + pass({ url: moz, pattern: ["*"] }); + pass({ url: moz, pattern: ["http://*"] }); + pass({ url: moz, pattern: ["*mozilla*"] }); + // pass({url: moz, pattern: ["*example*", "*mozilla*"]}); + + pass({ url: moz, pattern: ["*://*"] }); + pass({ url: "https://mozilla.org", pattern: ["*://*"] }); + + // Documentation example + pass({ + url: "http://www.example.com/foo/bar", + pattern: ["http://???.example.com/foo/*"], + }); + pass({ + url: "http://the.example.com/foo/", + pattern: ["http://???.example.com/foo/*"], + }); + fail({ + url: "http://my.example.com/foo/bar", + pattern: ["http://???.example.com/foo/*"], + }); + fail({ + url: "http://example.com/foo/", + pattern: ["http://???.example.com/foo/*"], + }); + fail({ + url: "http://www.example.com/foo", + pattern: ["http://???.example.com/foo/*"], + }); + + // Matches path + let path = moz + "/abc/def"; + pass({ url: path, pattern: ["*def"] }); + pass({ url: path, pattern: ["*c/d*"] }); + pass({ url: path, pattern: ["*org/abc*"] }); + fail({ url: path + "/", pattern: ["*def"] }); + + // Trailing slash + pass({ url: moz, pattern: ["*.org/"] }); + fail({ url: moz, pattern: ["*.org"] }); + + // Wrong TLD + fail({ url: moz, pattern: ["*oz*.com/"] }); + // Case sensitive + fail({ url: moz, pattern: ["*.ORG/"] }); +}); + +add_task(async function test_MatchGlob_redundant_wildcards_backtracking() { + const slow_build = + AppConstants.DEBUG || AppConstants.TSAN || AppConstants.ASAN; + const first_limit = slow_build ? 200 : 20; + { + // Bug 1570868 - repeated * in tabs.query glob causes too much backtracking. + let title = `Monster${"*".repeat(99)}Mash`; + + // The first run could take longer than subsequent runs, as the DFA is lazily created. + let first_start = Date.now(); + let glob = new MatchGlob(title); + let first_matches = glob.matches(title); + let first_duration = Date.now() - first_start; + ok(first_matches, `Expected match: ${title}, ${title}`); + Assert.less( + first_duration, + first_limit, + `First matching duration: ${first_duration}ms (limit: ${first_limit}ms)` + ); + + let start = Date.now(); + let matches = glob.matches(title); + let duration = Date.now() - start; + + ok(matches, `Expected match: ${title}, ${title}`); + Assert.less(duration, 10, `Matching duration: ${duration}ms`); + } + { + // Similarly with any continuous combination of ?**???****? wildcards. + let title = `Monster${"?*".repeat(99)}Mash`; + + // The first run could take longer than subsequent runs, as the DFA is lazily created. + let first_start = Date.now(); + let glob = new MatchGlob(title); + let first_matches = glob.matches(title); + let first_duration = Date.now() - first_start; + ok(first_matches, `Expected match: ${title}, ${title}`); + Assert.less( + first_duration, + first_limit, + `First matching duration: ${first_duration}ms (limit: ${first_limit}ms)` + ); + + let start = Date.now(); + let matches = glob.matches(title); + let duration = Date.now() - start; + + ok(matches, `Expected match: ${title}, ${title}`); + Assert.less(duration, 10, `Matching duration: ${duration}ms`); + } +}); + +add_task(async function test_MatchPattern_subsumes() { + function test(oldPat, newPat) { + let m = new MatchPatternSet(oldPat); + return m.subsumes(new MatchPattern(newPat)); + } + + function pass({ oldPat, newPat }) { + ok(test(oldPat, newPat), `${JSON.stringify(oldPat)} subsumes "${newPat}"`); + } + + function fail({ oldPat, newPat }) { + ok( + !test(oldPat, newPat), + `${JSON.stringify(oldPat)} doesn't subsume "${newPat}"` + ); + } + + pass({ oldPat: [""], newPat: "*://*/*" }); + pass({ oldPat: [""], newPat: "http://*/*" }); + pass({ oldPat: [""], newPat: "http://*.example.com/*" }); + + pass({ oldPat: ["*://*/*"], newPat: "http://*/*" }); + pass({ oldPat: ["*://*/*"], newPat: "wss://*/*" }); + pass({ oldPat: ["*://*/*"], newPat: "http://*.example.com/*" }); + + pass({ oldPat: ["*://*.example.com/*"], newPat: "http://*.example.com/*" }); + pass({ oldPat: ["*://*.example.com/*"], newPat: "*://sub.example.com/*" }); + + pass({ oldPat: ["https://*/*"], newPat: "https://*.example.com/*" }); + pass({ + oldPat: ["http://*.example.com/*"], + newPat: "http://subdomain.example.com/*", + }); + pass({ + oldPat: ["http://*.sub.example.com/*"], + newPat: "http://sub.example.com/*", + }); + pass({ + oldPat: ["http://*.sub.example.com/*"], + newPat: "http://sec.sub.example.com/*", + }); + pass({ + oldPat: ["http://www.example.com/*"], + newPat: "http://www.example.com/path/*", + }); + pass({ + oldPat: ["http://www.example.com/path/*"], + newPat: "http://www.example.com/*", + }); + + fail({ oldPat: ["*://*/*"], newPat: "" }); + fail({ oldPat: ["*://*/*"], newPat: "ftp://*/*" }); + fail({ oldPat: ["*://*/*"], newPat: "file://*/*" }); + + fail({ oldPat: ["http://example.com/*"], newPat: "*://example.com/*" }); + fail({ oldPat: ["http://example.com/*"], newPat: "https://example.com/*" }); + fail({ + oldPat: ["http://example.com/*"], + newPat: "http://otherexample.com/*", + }); + fail({ oldPat: ["http://example.com/*"], newPat: "http://*.example.com/*" }); + fail({ + oldPat: ["http://example.com/*"], + newPat: "http://subdomain.example.com/*", + }); + + fail({ + oldPat: ["http://subdomain.example.com/*"], + newPat: "http://example.com/*", + }); + fail({ + oldPat: ["http://subdomain.example.com/*"], + newPat: "http://*.example.com/*", + }); + fail({ + oldPat: ["http://sub.example.com/*"], + newPat: "http://*.sub.example.com/*", + }); + + fail({ oldPat: ["ws://example.com/*"], newPat: "wss://example.com/*" }); + fail({ oldPat: ["http://example.com/*"], newPat: "ws://example.com/*" }); + fail({ oldPat: ["https://example.com/*"], newPat: "wss://example.com/*" }); +}); + +add_task(async function test_MatchPattern_matchesAllWebUrls() { + function test(patterns, options) { + let m = new MatchPatternSet(patterns, options); + if (patterns.length === 1) { + // Sanity check: with a single pattern, MatchPatternSet and MatchPattern + // have equivalent outputs. + equal( + new MatchPattern(patterns[0], options).matchesAllWebUrls, + m.matchesAllWebUrls, + "matchesAllWebUrls() is consistent in MatchPattern and MatchPatternSet" + ); + } + return m.matchesAllWebUrls; + } + function pass(patterns, options) { + ok( + test(patterns, options), + `${JSON.stringify(patterns)} ${ + options ? JSON.stringify(options) : "" + } matches all web URLs` + ); + } + + function fail(patterns, options) { + ok( + !test(patterns, options), + `${JSON.stringify(patterns)} ${ + options ? JSON.stringify(options) : "" + } doesn't match all web URLs` + ); + } + + pass([""]); + pass(["*://*/*"]); + pass(["*://*/"], { ignorePath: true }); + + fail(["*://*/"]); // missing path wildcard. + fail(["http://*/*"]); + fail(["https://*/*"]); + fail(["wss://*/*"]); + fail(["ws://*/*"]); + fail(["file://*/*"]); + + // Edge case: unusual number of wildcards in path. + pass(["*://*/**"]); + pass(["*://*/***"]); + pass(["*://*/***"], { ignorePath: true }); + fail(["*://*//***"]); + + // After the singular cases, test non-single cases. + fail([]); + pass(["", "https://example.com/"]); + pass(["https://example.com/", "http://example.com/", "*://*/*"]); + + pass(["https://*/*", "http://*/*"]); + pass(["https://*/", "http://*/"], { ignorePath: true }); + fail(["https://*/", "http://*/"]); // missing path wildcard everywhere. + fail(["https://*/*", "http://*/"]); // missing http://*/*. + fail(["https://*/", "http://*/*"]); // missing https://*/*. +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_QuarantinedDomains.js b/toolkit/components/extensions/test/xpcshell/test_QuarantinedDomains.js new file mode 100644 index 0000000000..fdb243150b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_QuarantinedDomains.js @@ -0,0 +1,392 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +AddonTestUtils.overrideCertDB(); +AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("privileged"); + +add_setup(async () => { + await AddonTestUtils.promiseStartupManager(); +}); + +const server = createHttpServer({ hosts: ["example.org", "example.net"] }); +server.registerPathHandler("/", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write(""); +}); + +const ADDONS_RESTRICTED_DOMAINS_PREF = + "extensions.webextensions.addons-restricted-domains@mozilla.com.disabled"; + +const DOMAINS = [ + "addons-dev.allizom.org", + "mixed.badssl.com", + "careers.mozilla.com", + "developer.mozilla.org", + "test.example.com", +]; + +const CAN_ACCESS_ALL = DOMAINS.reduce((map, domain) => { + return { ...map, [domain]: true }; +}, {}); + +function makePolicy(options) { + return new WebExtensionPolicy({ + baseURL: "file:///foo/", + localizeCallback: str => str, + allowedOrigins: new MatchPatternSet([""], { ignorePath: true }), + mozExtensionHostname: Services.uuid.generateUUID().toString().slice(1, -1), + ...options, + }); +} + +function makeCS(policy) { + return new WebExtensionContentScript(policy, { + matches: new MatchPatternSet([""]), + }); +} + +function makeExtension({ id }) { + return ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id } }, + permissions: [""], + content_scripts: [ + { + js: ["script.js"], + matches: [""], + }, + ], + }, + useAddonManager: "permanent", + files: { + "script.js": ` + browser.test.sendMessage("tld", location.host.split(".").at(-1)); + browser.test.sendMessage("cs"); + `, + }, + }); +} + +function expectQuarantined(expectedDomains) { + for (let domain of DOMAINS) { + let uri = Services.io.newURI(`https://${domain}/`); + let quarantined = expectedDomains.includes(domain); + + equal( + quarantined, + WebExtensionPolicy.isQuarantinedURI(uri), + `Expect ${uri.spec} to ${quarantined ? "" : "not"} be quarantined.` + ); + } +} + +function expectAccess(policy, cs, expected) { + for (let domain of Object.keys(expected)) { + let uri = Services.io.newURI(`https://${domain}/`); + let access = expected[domain]; + let match = access; + + equal( + access, + !policy.quarantinedFromURI(uri), + `${policy.id} is ${access ? "not" : ""} quarantined from ${uri.spec}.` + ); + equal( + access, + policy.canAccessURI(uri), + `Expect ${policy.id} ${access ? "can" : "can't"} access ${uri.spec}.` + ); + + equal( + match, + cs.matchesURI(uri), + `Expect ${cs.extension.id} to ${match ? "" : "not"} match ${uri.spec}.` + ); + } +} + +function expectHost(desc, host, quarantined) { + let uri = Services.io.newURI(`https://${host}/`); + equal( + quarantined, + WebExtensionPolicy.isQuarantinedURI(uri), + `Expect ${desc} "${host}" to ${quarantined ? "" : "not"} be quarantined.` + ); +} + +function makePolicies() { + const plain = makePolicy({ id: "plain@test" }); + const system = makePolicy({ id: "system@test", isPrivileged: true }); + const exempt = makePolicy({ id: "exempt@test", ignoreQuarantine: true }); + + return { plain, system, exempt }; +} + +function makeContentScripts(policies) { + return policies.map(makeCS); +} + +add_task(async function test_QuarantinedDomains() { + const { plain, system, exempt } = makePolicies(); + const [plainCS, systemCS, exemptCS] = makeContentScripts([ + plain, + system, + exempt, + ]); + + info("Initial pref state is an empty list."); + expectQuarantined([]); + + expectAccess(plain, plainCS, CAN_ACCESS_ALL); + expectAccess(system, systemCS, CAN_ACCESS_ALL); + expectAccess(exempt, exemptCS, CAN_ACCESS_ALL); + + info("Default test domain list."); + Services.prefs.setStringPref( + "extensions.quarantinedDomains.list", + "addons-dev.allizom.org,mixed.badssl.com,test.example.com" + ); + + expectQuarantined([ + "addons-dev.allizom.org", + "mixed.badssl.com", + "test.example.com", + ]); + + const EXPECT_DEFAULTS = { + "addons-dev.allizom.org": false, + "mixed.badssl.com": false, + "careers.mozilla.com": true, + "developer.mozilla.org": true, + "test.example.com": false, + }; + + expectAccess(plain, plainCS, EXPECT_DEFAULTS); + expectAccess(system, systemCS, CAN_ACCESS_ALL); + expectAccess(exempt, exemptCS, CAN_ACCESS_ALL); + + info("Test changing policy.ignoreQuarantine after creation."); + + ok(!plain.ignoreQuarantine, "plain policy does not ignore quarantine."); + ok(system.ignoreQuarantine, "system policy does ignore quarantine."); + ok(exempt.ignoreQuarantine, "exempt policy does ignore quarantine."); + + plain.ignoreQuarantine = true; + system.ignoreQuarantine = false; + exempt.ignoreQuarantine = false; + + ok(plain.ignoreQuarantine, "expect plain.ignoreQuarantine to be true."); + ok(!system.ignoreQuarantine, "expect system.ignoreQuarantine to be false."); + ok(!exempt.ignoreQuarantine, "expect exempt.ignoreQuarantine to be false."); + + expectAccess(plain, plainCS, CAN_ACCESS_ALL); + expectAccess(system, systemCS, EXPECT_DEFAULTS); + expectAccess(exempt, exemptCS, EXPECT_DEFAULTS); + + plain.ignoreQuarantine = false; + system.ignoreQuarantine = true; + exempt.ignoreQuarantine = true; + + expectAccess(plain, plainCS, EXPECT_DEFAULTS); + expectAccess(system, systemCS, CAN_ACCESS_ALL); + expectAccess(exempt, exemptCS, CAN_ACCESS_ALL); + + info("Disable the Quarantined Domains feature."); + Services.prefs.setBoolPref("extensions.quarantinedDomains.enabled", false); + expectQuarantined([]); + + expectAccess(plain, plainCS, CAN_ACCESS_ALL); + expectAccess(system, systemCS, CAN_ACCESS_ALL); + expectAccess(exempt, exemptCS, CAN_ACCESS_ALL); + + info( + "Enable again, drop addons-dev.allizom.org and add developer.mozilla.org to the pref." + ); + Services.prefs.setBoolPref("extensions.quarantinedDomains.enabled", true); + + Services.prefs.setStringPref( + "extensions.quarantinedDomains.list", + "mixed.badssl.com,developer.mozilla.org,test.example.com" + ); + expectQuarantined([ + "mixed.badssl.com", + "developer.mozilla.org", + "test.example.com", + ]); + + expectAccess(plain, plainCS, { + "addons-dev.allizom.org": true, + "mixed.badssl.com": false, + "careers.mozilla.com": true, + "developer.mozilla.org": false, + "test.example.com": false, + }); + + expectAccess(system, systemCS, CAN_ACCESS_ALL); + expectAccess(exempt, exemptCS, CAN_ACCESS_ALL); + + expectHost("host with a port", "test.example.com:1025", true); + + expectHost("FQDN", "test.example.com.", false); + expectHost("subdomain", "subdomain.test.example.com", false); + expectHost("domain with prefix", "pretest.example.com", false); + expectHost("domain with suffix", "test.example.comsuf", false); +}); + +function setIgnorePref(id, value = false) { + Services.prefs.setBoolPref(`extensions.quarantineIgnoredByUser.${id}`, value); +} + +// Test that ignore prefs take effect in the content process. +add_task( + { pref_set: [["extensions.quarantinedDomains.list", "example.net"]] }, + async function test_QuarantinedDomains_ignored() { + const EXPECT_DEFAULTS = { "example.org": true, "example.net": false }; + const CAN_ACCESS_ALL = { "example.org": true, "example.net": true }; + + let alpha = makeExtension({ id: "alpha@test" }); + let beta = makeExtension({ id: "beta@test" }); + let system = makeExtension({ id: "privileged@test" }); + + let page = await ExtensionTestUtils.loadContentPage("about:blank"); + + await alpha.startup(); + await beta.startup(); + await system.startup(); + + equal(system.extension.isPrivileged, true, "is privileged"); + + let alphaPolicy = alpha.extension.policy; + let betaPolicy = beta.extension.policy; + + let alphaCounters = { org: 0, net: 0 }; + let betaCounters = { org: 0, net: 0 }; + + alpha.onMessage("tld", tld => alphaCounters[tld]++); + beta.onMessage("tld", tld => betaCounters[tld]++); + system.onMessage("tld", () => {}); + + async function testTLD(tld, expectAlpha, expectBeta) { + let alphaCount = alphaCounters[tld]; + let betaCount = betaCounters[tld]; + + await page.loadURL(`http://example.${tld}/`); + if (expectAlpha) { + await alpha.awaitMessage("cs"); + alphaCount++; + } + if (expectBeta) { + await beta.awaitMessage("cs"); + betaCount++; + } + // Sanity check, plus always having something to await. + await system.awaitMessage("cs"); + + equal(alphaCount, alphaCounters[tld], `Expected ${tld} alpha CS counter`); + equal(betaCount, betaCounters[tld], `Expected ${tld} beta CS counter`); + } + + info("Test defaults, example.org is accessible, example.net is not."); + + await testTLD("org", true, true); + await testTLD("net", false, false); + + expectAccess(alphaPolicy, alphaPolicy.contentScripts[0], EXPECT_DEFAULTS); + expectAccess(betaPolicy, betaPolicy.contentScripts[0], EXPECT_DEFAULTS); + + info("Test setting the pref for alpha@test."); + setIgnorePref("alpha@test", true); + + await testTLD("net", true, false); + await testTLD("org", true, true); + + expectAccess(alphaPolicy, alphaPolicy.contentScripts[0], CAN_ACCESS_ALL); + expectAccess(betaPolicy, betaPolicy.contentScripts[0], EXPECT_DEFAULTS); + + info("Test setting the pref for beta@test."); + setIgnorePref("beta@test", true); + + await testTLD("org", true, true); + await testTLD("net", true, true); + expectAccess(betaPolicy, betaPolicy.contentScripts[0], CAN_ACCESS_ALL); + + info("Test unsetting the pref for alpha@test."); + setIgnorePref("alpha@test", false); + + await testTLD("net", false, true); + await testTLD("org", true, true); + + info("Test unsetting the pref for beta@test."); + setIgnorePref("beta@test", false); + + await testTLD("org", true, true); + await testTLD("net", false, false); + + expectAccess(alphaPolicy, alphaPolicy.contentScripts[0], EXPECT_DEFAULTS); + expectAccess(betaPolicy, betaPolicy.contentScripts[0], EXPECT_DEFAULTS); + + Assert.deepEqual( + alphaCounters, + { org: 5, net: 2 }, + "Expected final Alpha content script counters." + ); + + Assert.deepEqual( + betaCounters, + { org: 5, net: 2 }, + "Expected final Beta content script counters." + ); + + await system.unload(); + await beta.unload(); + await alpha.unload(); + + await page.close(); + } +); + +// Make sure we honor the system add-on pref. +add_task( + { + pref_set: [ + [ADDONS_RESTRICTED_DOMAINS_PREF, true], + [ + "extensions.quarantinedDomains.list", + "addons-dev.allizom.org,mixed.badssl.com,test.example.com", + ], + ], + }, + async function test_QuarantinedDomains_with_system_addon_disabled() { + await AddonTestUtils.promiseRestartManager(); + + const { plain, system, exempt } = makePolicies(); + const [plainCS, systemCS, exemptCS] = makeContentScripts([ + plain, + system, + exempt, + ]); + + expectQuarantined([]); + expectAccess(plain, plainCS, CAN_ACCESS_ALL); + expectAccess(system, systemCS, CAN_ACCESS_ALL); + expectAccess(exempt, exemptCS, CAN_ACCESS_ALL); + + // When the user changes this pref to re-enable the system add-on... + Services.prefs.setBoolPref(ADDONS_RESTRICTED_DOMAINS_PREF, false); + // ...after a AOM restart... + await AddonTestUtils.promiseRestartManager(); + // ...we expect no change. + expectQuarantined([]); + expectAccess(plain, plainCS, CAN_ACCESS_ALL); + expectAccess(system, systemCS, CAN_ACCESS_ALL); + expectAccess(exempt, exemptCS, CAN_ACCESS_ALL); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_QuarantinedDomains_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_QuarantinedDomains_telemetry.js new file mode 100644 index 0000000000..c18785b3d6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_QuarantinedDomains_telemetry.js @@ -0,0 +1,99 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +ChromeUtils.defineESModuleGetters(this, { + computeSha1HashAsString: "resource://gre/modules/addons/crypto-utils.sys.mjs", + QuarantinedDomains: "resource://gre/modules/ExtensionPermissions.sys.mjs", +}); + +add_setup(() => { + setupTelemetryForTests(); +}); + +add_task(async function test_QuarantinedDomainsList_telemetry() { + const cleanupPrefs = () => { + Services.prefs.clearUserPref(QuarantinedDomains.PREF_DOMAINSLIST_NAME); + }; + registerCleanupFunction(cleanupPrefs); + + const assertDomainsListTelemetry = ({ prefValue, expected }) => { + resetTelemetryData(); + Services.prefs.setStringPref( + QuarantinedDomains.PREF_DOMAINSLIST_NAME, + prefValue + ); + Assert.deepEqual( + { + listsize: QuarantinedDomains.currentDomainsList.set.size, + listhash: QuarantinedDomains.currentDomainsList.hash, + }, + expected, + "Got the expected domains list data computed for the probes" + ); + Assert.deepEqual( + { + listsize: Glean.extensionsQuarantinedDomains.listsize.testGetValue(), + listhash: Glean.extensionsQuarantinedDomains.listhash.testGetValue(), + }, + expected, + "Got the expected computed domains list probes recorded by the Glean metrics" + ); + const scalars = Services.telemetry.getSnapshotForScalars().parent; + Assert.deepEqual( + { + listsize: scalars?.["extensions.quarantinedDomains.listsize"], + listhash: scalars?.["extensions.quarantinedDomains.listhash"], + }, + expected, + "Got the expected metrics mirrored into the unified telemetry scalars" + ); + }; + + let prefValue; + + info("Verify Glean 'Quarantined Domains list' probes on empty domain list"); + prefValue = ""; + assertDomainsListTelemetry({ + prefValue, + expected: { + listsize: 0, + listhash: computeSha1HashAsString(prefValue), + }, + }); + + info( + "Verify Glean 'Quarantined Domains list' probes on non-empty domain list" + ); + prefValue = "example.com,example.org"; + assertDomainsListTelemetry({ + prefValue, + expected: { + listsize: 2, + listhash: computeSha1HashAsString(prefValue), + }, + }); + + info( + "Verify Glean 'Quarantined Domains list' probes on non-empty domain list with duplicated domains" + ); + prefValue = "example.com,example.org, example.org, example.com "; + assertDomainsListTelemetry({ + prefValue, + expected: { + listsize: 2, + listhash: computeSha1HashAsString(prefValue), + }, + }); + + cleanupPrefs(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_StorageSyncService.js b/toolkit/components/extensions/test/xpcshell/test_StorageSyncService.js new file mode 100644 index 0000000000..ef55ed37e8 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_StorageSyncService.js @@ -0,0 +1,274 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const NS_ERROR_DOM_QUOTA_EXCEEDED_ERR = 0x80530016; + +XPCOMUtils.defineLazyServiceGetter( + this, + "StorageSyncService", + "@mozilla.org/extensions/storage/sync;1", + "nsIInterfaceRequestor" +); + +function promisify(func, ...params) { + return new Promise((resolve, reject) => { + let changes = []; + func(...params, { + QueryInterface: ChromeUtils.generateQI([ + "mozIExtensionStorageListener", + "mozIExtensionStorageCallback", + "mozIBridgedSyncEngineCallback", + "mozIBridgedSyncEngineApplyCallback", + ]), + onChanged(extId, json) { + changes.push({ extId, changes: JSON.parse(json) }); + }, + handleSuccess(value) { + resolve({ + changes, + value: typeof value == "string" ? JSON.parse(value) : value, + }); + }, + handleError(code, message) { + reject(Components.Exception(message, code)); + }, + }); + }); +} + +add_task(async function setup_storage_sync() { + // So that we can write to the profile directory. + do_get_profile(); +}); + +add_task(async function test_storage_sync_service() { + const service = StorageSyncService.getInterface(Ci.mozIExtensionStorageArea); + { + let { changes, value } = await promisify( + service.set, + "ext-1", + JSON.stringify({ + hi: "hello! 💖", + bye: "adiós", + }) + ); + deepEqual( + changes, + [ + { + extId: "ext-1", + changes: { + hi: { + newValue: "hello! 💖", + }, + bye: { + newValue: "adiós", + }, + }, + }, + ], + "`set` should notify listeners about changes" + ); + ok(!value, "`set` should not return a value"); + } + + { + let { changes, value } = await promisify( + service.get, + "ext-1", + JSON.stringify(["hi"]) + ); + deepEqual(changes, [], "`get` should not notify listeners"); + deepEqual( + value, + { + hi: "hello! 💖", + }, + "`get` with key should return value" + ); + + let { value: allValues } = await promisify(service.get, "ext-1", "null"); + deepEqual( + allValues, + { + hi: "hello! 💖", + bye: "adiós", + }, + "`get` without a key should return all values" + ); + } + + { + await promisify( + service.set, + "ext-2", + JSON.stringify({ + hi: "hola! 👋", + }) + ); + await promisify(service.clear, "ext-1"); + let { value: allValues } = await promisify(service.get, "ext-1", "null"); + deepEqual(allValues, {}, "clear removed ext-1"); + + let { value: allValues2 } = await promisify(service.get, "ext-2", "null"); + deepEqual(allValues2, { hi: "hola! 👋" }, "clear didn't remove ext-2"); + // We need to clear data for ext-2 too, so later tests don't fail due to + // this data. + await promisify(service.clear, "ext-2"); + } +}); + +add_task(async function test_storage_sync_bridged_engine() { + const area = StorageSyncService.getInterface(Ci.mozIExtensionStorageArea); + const engine = StorageSyncService.getInterface(Ci.mozIBridgedSyncEngine); + + info("Add some local items"); + await promisify(area.set, "ext-1", JSON.stringify({ a: "abc" })); + await promisify(area.set, "ext-2", JSON.stringify({ b: "xyz" })); + + info("Start a sync"); + await promisify(engine.syncStarted); + + info("Store some incoming synced items"); + let incomingEnvelopesAsJSON = [ + { + id: "guidAAA", + modified: 0.1, + payload: JSON.stringify({ + extId: "ext-2", + data: JSON.stringify({ + c: 1234, + }), + }), + }, + { + id: "guidBBB", + modified: 0.1, + payload: JSON.stringify({ + extId: "ext-3", + data: JSON.stringify({ + d: "new! ✨", + }), + }), + }, + ].map(e => JSON.stringify(e)); + await promisify(area.storeIncoming, incomingEnvelopesAsJSON); + + info("Merge"); + // Three levels of JSON wrapping: each outgoing envelope, the cleartext in + // each envelope, and the extension storage data in each cleartext payload. + let { value: outgoingEnvelopesAsJSON } = await promisify(area.apply); + let outgoingEnvelopes = outgoingEnvelopesAsJSON.map(json => JSON.parse(json)); + let parsedCleartexts = outgoingEnvelopes.map(e => JSON.parse(e.payload)); + let parsedData = parsedCleartexts.map(c => JSON.parse(c.data)); + + let { changes } = await promisify( + area.QueryInterface(Ci.mozISyncedExtensionStorageArea) + .fetchPendingSyncChanges + ); + deepEqual( + changes, + [ + { + extId: "ext-2", + changes: { + c: { newValue: 1234 }, + }, + }, + { + extId: "ext-3", + changes: { + d: { newValue: "new! ✨" }, + }, + }, + ], + "Should return pending synced changes for observers" + ); + + // ext-1 doesn't exist remotely yet, so the Rust sync layer will generate + // a GUID for it. We don't know what it is, so we find it by the extension + // ID. + let ext1Index = parsedCleartexts.findIndex(c => c.extId == "ext-1"); + greater(ext1Index, -1, "Should find envelope for ext-1"); + let ext1Guid = outgoingEnvelopes[ext1Index].id; + + // ext-2 has a remote GUID that we set in the test above. + let ext2Index = outgoingEnvelopes.findIndex(c => c.id == "guidAAA"); + greater(ext2Index, -1, "Should find envelope for ext-2"); + + equal(outgoingEnvelopes.length, 2, "Should upload ext-1 and ext-2"); + deepEqual( + parsedData[ext1Index], + { + a: "abc", + }, + "Should upload new data for ext-1" + ); + deepEqual( + parsedData[ext2Index], + { + b: "xyz", + c: 1234, + }, + "Should merge local and remote data for ext-2" + ); + + info("Mark all extensions as uploaded"); + await promisify(engine.setUploaded, 0, [ext1Guid, "guidAAA"]); + + info("Finish sync"); + await promisify(engine.syncFinished); + + // Try fetching values for the remote-only extension we just synced. + let { value: ext3Value } = await promisify(area.get, "ext-3", "null"); + deepEqual( + ext3Value, + { + d: "new! ✨", + }, + "Should return new keys for ext-3" + ); + + info("Try applying a second time"); + let secondApply = await promisify(area.apply); + deepEqual(secondApply.value, {}, "Shouldn't merge anything on second apply"); + + info("Wipe all items"); + await promisify(engine.wipe); + + for (let extId of ["ext-1", "ext-2", "ext-3"]) { + // `get` always returns an object, even if there are no keys for the + // extension ID. + let { value } = await promisify(area.get, extId, "null"); + deepEqual(value, {}, `Wipe should remove all values for ${extId}`); + } +}); + +add_task(async function test_storage_sync_quota() { + const service = StorageSyncService.getInterface(Ci.mozIExtensionStorageArea); + const engine = StorageSyncService.getInterface(Ci.mozIBridgedSyncEngine); + await promisify(engine.wipe); + await promisify(service.set, "ext-1", JSON.stringify({ x: "hi" })); + await promisify(service.set, "ext-1", JSON.stringify({ longer: "value" })); + + let { value: v1 } = await promisify(service.getBytesInUse, "ext-1", '"x"'); + Assert.equal(v1, 5); // key len without quotes, value len with quotes. + let { value: v2 } = await promisify(service.getBytesInUse, "ext-1", "null"); + // 5 from 'x', plus 'longer' (6 for key, 7 for value = 13) = 18. + Assert.equal(v2, 18); + + // Now set something greater than our quota. + await Assert.rejects( + promisify( + service.set, + "ext-1", + JSON.stringify({ + big: "x".repeat(Ci.mozIExtensionStorageArea.SYNC_QUOTA_BYTES), + }) + ), + ex => ex.result == NS_ERROR_DOM_QUOTA_EXCEEDED_ERR, + "should reject with NS_ERROR_DOM_QUOTA_EXCEEDED_ERR" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js b/toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js new file mode 100644 index 0000000000..fe05893f84 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js @@ -0,0 +1,323 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { newURI } = Services.io; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +async function test_url_matching({ + manifestVersion = 2, + allowedOrigins = [], + checkPermissions, + expectMatches, +}) { + let policy = new WebExtensionPolicy({ + id: "foo@bar.baz", + mozExtensionHostname: "88fb51cd-159f-4859-83db-7065485bc9b2", + baseURL: "file:///foo", + + manifestVersion, + allowedOrigins: new MatchPatternSet(allowedOrigins), + localizeCallback() {}, + }); + + let contentScript = new WebExtensionContentScript(policy, { + checkPermissions, + + matches: new MatchPatternSet(["http://*.foo.com/bar", "*://bar.com/baz/*"]), + + excludeMatches: new MatchPatternSet(["*://bar.com/baz/quux"]), + + includeGlobs: ["*flerg*", "*.com/bar", "*/quux"].map( + glob => new MatchGlob(glob) + ), + + excludeGlobs: ["*glorg*"].map(glob => new MatchGlob(glob)), + }); + + equal( + expectMatches, + contentScript.matchesURI(newURI("http://www.foo.com/bar")), + `Simple matches include should ${expectMatches ? "" : "not "} match.` + ); + + equal( + expectMatches, + contentScript.matchesURI(newURI("https://bar.com/baz/xflergx")), + `Simple matches include should ${expectMatches ? "" : "not "} match.` + ); + + ok( + !contentScript.matchesURI(newURI("https://bar.com/baz/xx")), + "Failed includeGlobs match pattern should not match" + ); + + ok( + !contentScript.matchesURI(newURI("https://bar.com/baz/quux")), + "Excluded match pattern should not match" + ); + + ok( + !contentScript.matchesURI(newURI("https://bar.com/baz/xflergxglorgx")), + "Excluded match glob should not match" + ); +} + +add_task(function test_WebExtensionContentScript_urls_mv2() { + return test_url_matching({ manifestVersion: 2, expectMatches: true }); +}); + +add_task(function test_WebExtensionContentScript_urls_mv2_checkPermissions() { + return test_url_matching({ + manifestVersion: 2, + checkPermissions: true, + expectMatches: false, + }); +}); + +add_task(function test_WebExtensionContentScript_urls_mv2_with_permissions() { + return test_url_matching({ + manifestVersion: 2, + checkPermissions: true, + allowedOrigins: [""], + expectMatches: true, + }); +}); + +add_task(function test_WebExtensionContentScript_urls_mv3() { + // checkPermissions ignored here because it's forced for MV3. + return test_url_matching({ + manifestVersion: 3, + checkPermissions: false, + expectMatches: false, + }); +}); + +add_task(function test_WebExtensionContentScript_mv3_all_urls() { + return test_url_matching({ + manifestVersion: 3, + allowedOrigins: [""], + expectMatches: true, + }); +}); + +add_task(function test_WebExtensionContentScript_mv3_wildcards() { + return test_url_matching({ + manifestVersion: 3, + allowedOrigins: ["*://*.foo.com/*", "*://*.bar.com/*"], + expectMatches: true, + }); +}); + +add_task(function test_WebExtensionContentScript_mv3_specific() { + return test_url_matching({ + manifestVersion: 3, + allowedOrigins: ["http://www.foo.com/*", "https://bar.com/*"], + expectMatches: true, + }); +}); + +add_task(function test_WebExtensionContentScript_restricted() { + let tests = [ + { + manifestVersion: 2, + permissions: [], + expect: false, + }, + { + manifestVersion: 2, + permissions: ["mozillaAddons"], + expect: true, + }, + { + manifestVersion: 3, + permissions: [], + expect: false, + }, + { + manifestVersion: 3, + permissions: ["mozillaAddons"], + expect: true, + }, + ]; + + for (let { manifestVersion, permissions, expect } of tests) { + let policy = new WebExtensionPolicy({ + id: "foo@bar.baz", + mozExtensionHostname: "88fb51cd-159f-4859-83db-7065485bc9b2", + baseURL: "file:///foo", + + manifestVersion, + permissions, + allowedOrigins: new MatchPatternSet([""]), + localizeCallback() {}, + }); + let contentScript = new WebExtensionContentScript(policy, { + checkPermissions: true, + matches: new MatchPatternSet([""]), + }); + + // AMO is on the extensions.webextensions.restrictedDomains list. + equal( + expect, + contentScript.matchesURI(newURI("https://addons.mozilla.org/foo")), + `Expect extension with [${permissions}] to ${expect ? "" : "not"} match` + ); + } +}); + +async function test_frame_matching(meta) { + if (AppConstants.platform == "linux") { + // The windowless browser currently does not load correctly on Linux on + // infra. + return; + } + + let baseURL = `http://example.com/data`; + let urls = { + topLevel: `${baseURL}/file_toplevel.html`, + iframe: `${baseURL}/file_iframe.html`, + srcdoc: "about:srcdoc", + aboutBlank: "about:blank", + }; + + let contentPage = await ExtensionTestUtils.loadContentPage(urls.topLevel); + + let tests = [ + { + matches: ["http://example.com/data/*"], + contentScript: {}, + topLevel: true, + iframe: false, + aboutBlank: false, + srcdoc: false, + }, + + { + matches: ["http://example.com/data/*"], + contentScript: { + frameID: 0, + }, + topLevel: true, + iframe: false, + aboutBlank: false, + srcdoc: false, + }, + + { + matches: ["http://example.com/data/*"], + contentScript: { + allFrames: true, + }, + topLevel: true, + iframe: true, + aboutBlank: false, + srcdoc: false, + }, + + { + matches: ["http://example.com/data/*"], + contentScript: { + allFrames: true, + matchAboutBlank: true, + }, + topLevel: true, + iframe: true, + aboutBlank: true, + srcdoc: true, + }, + + { + matches: ["http://foo.com/data/*"], + contentScript: { + allFrames: true, + matchAboutBlank: true, + }, + topLevel: false, + iframe: false, + aboutBlank: false, + srcdoc: false, + }, + ]; + + // matchesWindowGlobal tests against content frames + await contentPage.spawn([{ tests, urls, meta }], args => { + let { manifestVersion = 2, allowedOrigins = [], expectMatches } = args.meta; + + this.windows = new Map(); + this.windows.set(this.content.location.href, this.content); + for (let c of Array.from(this.content.frames)) { + this.windows.set(c.location.href, c); + } + const { MatchPatternSet, WebExtensionContentScript, WebExtensionPolicy } = + Cu.getGlobalForObject(Services); + this.policy = new WebExtensionPolicy({ + id: "foo@bar.baz", + mozExtensionHostname: "88fb51cd-159f-4859-83db-7065485bc9b2", + baseURL: "file:///foo", + + manifestVersion, + allowedOrigins: new MatchPatternSet(allowedOrigins), + localizeCallback() {}, + }); + + let tests = args.tests.map(t => { + t.contentScript.matches = new MatchPatternSet(t.matches); + t.script = new WebExtensionContentScript(this.policy, t.contentScript); + return t; + }); + for (let [i, test] of tests.entries()) { + for (let [frame, url] of Object.entries(args.urls)) { + let should = test[frame] ? "should" : "should not"; + let wgc = this.windows.get(url).windowGlobalChild; + Assert.equal( + test.script.matchesWindowGlobal(wgc), + test[frame] && expectMatches, + `Script ${i} ${should} match the ${frame} frame` + ); + } + } + }); + + await contentPage.close(); +} + +add_task(function test_WebExtensionContentScript_frames_mv2() { + return test_frame_matching({ + manifestVersion: 2, + expectMatches: true, + }); +}); + +add_task(function test_WebExtensionContentScript_frames_mv3() { + return test_frame_matching({ + manifestVersion: 3, + expectMatches: false, + }); +}); + +add_task(function test_WebExtensionContentScript_frames_mv3_all_urls() { + return test_frame_matching({ + manifestVersion: 3, + allowedOrigins: [""], + expectMatches: true, + }); +}); + +add_task(function test_WebExtensionContentScript_frames_mv3_wildcards() { + return test_frame_matching({ + manifestVersion: 3, + allowedOrigins: ["*://*.example.com/*"], + expectMatches: true, + }); +}); + +add_task(function test_WebExtensionContentScript_frames_mv3_specific() { + return test_frame_matching({ + manifestVersion: 3, + allowedOrigins: ["http://example.com/*"], + expectMatches: true, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js b/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js new file mode 100644 index 0000000000..ff2cc3c2ac --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js @@ -0,0 +1,620 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { newURI } = Services.io; + +add_task(async function test_WebExtensionPolicy() { + const id = "foo@bar.baz"; + const uuid = "ca9d3f23-125c-4b24-abfc-1ca2692b0610"; + + const baseURL = "file:///foo/"; + const mozExtURL = `moz-extension://${uuid}/`; + const mozExtURI = newURI(mozExtURL); + + let policy = new WebExtensionPolicy({ + id, + mozExtensionHostname: uuid, + baseURL, + + localizeCallback(str) { + return `<${str}>`; + }, + + allowedOrigins: new MatchPatternSet(["http://foo.bar/", "*://*.baz/"], { + ignorePath: true, + }), + permissions: [""], + webAccessibleResources: [ + { + resources: ["/foo/*", "/bar.baz"].map(glob => new MatchGlob(glob)), + }, + ], + }); + + equal(policy.active, false, "Active attribute should initially be false"); + + // GetURL + + equal( + policy.getURL(), + mozExtURL, + "getURL() should return the correct root URL" + ); + equal( + policy.getURL("path/foo.html"), + `${mozExtURL}path/foo.html`, + "getURL(path) should return the correct URL" + ); + + // Permissions + + deepEqual( + policy.permissions, + [""], + "Initial permissions should be correct" + ); + + ok( + policy.hasPermission(""), + "hasPermission should match existing permission" + ); + ok( + !policy.hasPermission("history"), + "hasPermission should not match nonexistent permission" + ); + + Assert.throws( + () => { + policy.permissions[0] = "foo"; + }, + TypeError, + "Permissions array should be frozen" + ); + + policy.permissions = ["history"]; + deepEqual( + policy.permissions, + ["history"], + "Permissions should be updateable as a set" + ); + + ok( + policy.hasPermission("history"), + "hasPermission should match existing permission" + ); + ok( + !policy.hasPermission(""), + "hasPermission should not match nonexistent permission" + ); + + // Origins + + ok( + policy.canAccessURI(newURI("http://foo.bar/quux")), + "Should be able to access permitted URI" + ); + ok( + policy.canAccessURI(newURI("https://x.baz/foo")), + "Should be able to access permitted URI" + ); + + ok( + !policy.canAccessURI(newURI("https://foo.bar/quux")), + "Should not be able to access non-permitted URI" + ); + + policy.allowedOrigins = new MatchPatternSet(["https://foo.bar/"], { + ignorePath: true, + }); + + ok( + policy.canAccessURI(newURI("https://foo.bar/quux")), + "Should be able to access updated permitted URI" + ); + ok( + !policy.canAccessURI(newURI("https://x.baz/foo")), + "Should not be able to access removed permitted URI" + ); + + // Web-accessible resources + + ok( + policy.isWebAccessiblePath("/foo/bar"), + "Web-accessible glob should be web-accessible" + ); + ok( + policy.isWebAccessiblePath("/bar.baz"), + "Web-accessible path should be web-accessible" + ); + ok( + !policy.isWebAccessiblePath("/bar.baz/quux"), + "Non-web-accessible path should not be web-accessible" + ); + + ok( + policy.sourceMayAccessPath(mozExtURI, "/bar.baz"), + "Web-accessible path should be web-accessible to self" + ); + + // Localization + + equal( + policy.localize("foo"), + "", + "Localization callback should work as expected" + ); + + // Protocol and lookups. + + let proto = Services.io + .getProtocolHandler("moz-extension", uuid) + .QueryInterface(Ci.nsISubstitutingProtocolHandler); + + deepEqual( + WebExtensionPolicy.getActiveExtensions(), + [], + "Should have no active extensions" + ); + equal( + WebExtensionPolicy.getByID(id), + null, + "ID lookup should not return extension when not active" + ); + equal( + WebExtensionPolicy.getByHostname(uuid), + null, + "Hostname lookup should not return extension when not active" + ); + Assert.throws( + () => proto.resolveURI(mozExtURI), + /NS_ERROR_NOT_AVAILABLE/, + "URL should not resolve when not active" + ); + + policy.active = true; + equal(policy.active, true, "Active attribute should be updated"); + + let exts = WebExtensionPolicy.getActiveExtensions(); + equal(exts.length, 1, "Should have one active extension"); + equal(exts[0], policy, "Should have the correct active extension"); + + equal( + WebExtensionPolicy.getByID(id), + policy, + "ID lookup should return extension when active" + ); + equal( + WebExtensionPolicy.getByHostname(uuid), + policy, + "Hostname lookup should return extension when active" + ); + + equal( + proto.resolveURI(mozExtURI), + baseURL, + "URL should resolve correctly while active" + ); + + policy.active = false; + equal(policy.active, false, "Active attribute should be updated"); + + deepEqual( + WebExtensionPolicy.getActiveExtensions(), + [], + "Should have no active extensions" + ); + equal( + WebExtensionPolicy.getByID(id), + null, + "ID lookup should not return extension when not active" + ); + equal( + WebExtensionPolicy.getByHostname(uuid), + null, + "Hostname lookup should not return extension when not active" + ); + Assert.throws( + () => proto.resolveURI(mozExtURI), + /NS_ERROR_NOT_AVAILABLE/, + "URL should not resolve when not active" + ); + + // Conflicting policies. + + // This asserts in debug builds, so only test in non-debug builds. + if (!AppConstants.DEBUG) { + policy.active = true; + + let attrs = [ + { id, uuid }, + { id, uuid: "d916886c-cfdf-482e-b7b1-d7f5b0facfa5" }, + { id: "foo@quux", uuid }, + ]; + + // eslint-disable-next-line no-shadow + for (let { id, uuid } of attrs) { + let policy2 = new WebExtensionPolicy({ + id, + mozExtensionHostname: uuid, + baseURL: "file://bar/", + + localizeCallback() {}, + + allowedOrigins: new MatchPatternSet([]), + }); + + Assert.throws( + () => { + policy2.active = true; + }, + /NS_ERROR_UNEXPECTED/, + `Should not be able to activate conflicting policy: ${id} ${uuid}` + ); + } + + policy.active = false; + } +}); + +// mozExtensionHostname is normalized to lower case when using +// policy.getURL whereas using policy.getByHostname does +// not. Tests below will fail without case insensitive +// comparisons in ExtensionPolicyService +add_task(async function test_WebExtensionPolicy_case_sensitivity() { + const id = "policy-case@mochitest"; + const uuid = "BAD93A23-125C-4B24-ABFC-1CA2692B0610"; + + const baseURL = "file:///foo/"; + const mozExtURL = `moz-extension://${uuid}/`; + const mozExtURI = newURI(mozExtURL); + + let policy = new WebExtensionPolicy({ + id: id, + mozExtensionHostname: uuid, + baseURL, + localizeCallback() {}, + allowedOrigins: new MatchPatternSet([]), + permissions: [""], + }); + policy.active = true; + + equal( + WebExtensionPolicy.getByHostname(uuid)?.mozExtensionHostname, + policy.mozExtensionHostname, + "Hostname lookup should match policy" + ); + + equal( + WebExtensionPolicy.getByHostname(uuid.toLowerCase())?.mozExtensionHostname, + policy.mozExtensionHostname, + "Hostname lookup should match policy" + ); + + equal(policy.getURL(), mozExtURI.spec, "Urls should match policy"); + ok( + policy.sourceMayAccessPath(mozExtURI, "/bar.baz"), + "Extension path should be accessible to self" + ); + + policy.active = false; +}); + +add_task(async function test_WebExtensionPolicy_V3() { + const id = "foo@bar.baz"; + const uuid = "ca9d3f23-125c-4b24-abfc-1ca2692b0610"; + const id2 = "foo-2@bar.baz"; + const uuid2 = "89383c45-7db4-4999-83f7-f4cc246372cd"; + const id3 = "foo-3@bar.baz"; + const uuid3 = "56652231-D7E2-45D1-BDBD-BD3BFF80927E"; + + const baseURL = "file:///foo/"; + const mozExtURL = `moz-extension://${uuid}/`; + const mozExtURI = newURI(mozExtURL); + const fooSite = newURI("http://foo.bar/"); + const exampleSite = newURI("https://example.com/"); + + let policy = new WebExtensionPolicy({ + id, + mozExtensionHostname: uuid, + baseURL, + manifestVersion: 3, + + localizeCallback(str) { + return `<${str}>`; + }, + + allowedOrigins: new MatchPatternSet(["http://foo.bar/", "*://*.baz/"], { + ignorePath: true, + }), + permissions: [""], + webAccessibleResources: [ + { + resources: ["/foo/*", "/bar.baz"].map(glob => new MatchGlob(glob)), + matches: ["http://foo.bar/"], + extension_ids: [id3], + }, + { + resources: ["/foo.bar.baz"].map(glob => new MatchGlob(glob)), + extension_ids: ["*"], + }, + ], + }); + policy.active = true; + equal( + WebExtensionPolicy.getByHostname(uuid), + policy, + "Hostname lookup should match policy" + ); + + let policy2 = new WebExtensionPolicy({ + id: id2, + mozExtensionHostname: uuid2, + baseURL, + localizeCallback() {}, + allowedOrigins: new MatchPatternSet([]), + permissions: [""], + }); + policy2.active = true; + equal( + WebExtensionPolicy.getByHostname(uuid2), + policy2, + "Hostname lookup should match policy" + ); + + let policy3 = new WebExtensionPolicy({ + id: id3, + mozExtensionHostname: uuid3, + baseURL, + localizeCallback() {}, + allowedOrigins: new MatchPatternSet([]), + permissions: [""], + }); + policy3.active = true; + equal( + WebExtensionPolicy.getByHostname(uuid3), + policy3, + "Hostname lookup should match policy" + ); + + ok( + policy.isWebAccessiblePath("/bar.baz"), + "Web-accessible path should be web-accessible" + ); + ok( + !policy.isWebAccessiblePath("/bar.baz/quux"), + "Non-web-accessible path should not be web-accessible" + ); + // Extension can always access itself + ok( + policy.sourceMayAccessPath(mozExtURI, "/bar.baz"), + "Web-accessible path should be accessible to self" + ); + ok( + policy.sourceMayAccessPath(mozExtURI, "/foo.bar.baz"), + "Web-accessible path should be accessible to self" + ); + + ok( + !policy.sourceMayAccessPath(newURI(`https://${uuid}/`), "/bar.baz"), + "Web-accessible path should not be accessible due to scheme mismatch" + ); + + // non-matching site cannot access url + ok( + policy.sourceMayAccessPath(fooSite, "/bar.baz"), + "Web-accessible path should be accessible to foo.bar site" + ); + ok( + !policy.sourceMayAccessPath(fooSite, "/foo.bar.baz"), + "Web-accessible path should not be accessible to foo.bar site" + ); + + // non-matching site cannot access url + ok( + !policy.sourceMayAccessPath(exampleSite, "/bar.baz"), + "Web-accessible path should not be accessible to example.com" + ); + ok( + !policy.sourceMayAccessPath(exampleSite, "/foo.bar.baz"), + "Web-accessible path should not be accessible to example.com" + ); + + let extURI = newURI(policy2.getURL("")); + ok( + !policy.sourceMayAccessPath(extURI, "/bar.baz"), + "Web-accessible path should not be accessible to other extension" + ); + ok( + policy.sourceMayAccessPath(extURI, "/foo.bar.baz"), + "Web-accessible path should be accessible to other extension" + ); + + extURI = newURI(policy3.getURL("")); + ok( + policy.sourceMayAccessPath(extURI, "/bar.baz"), + "Web-accessible path should be accessible to other extension" + ); + ok( + policy.sourceMayAccessPath(extURI, "/foo.bar.baz"), + "Web-accessible path should be accessible to other extension" + ); + + policy.active = false; + policy2.active = false; + policy3.active = false; +}); + +add_task(async function test_WebExtensionPolicy_registerContentScripts() { + const id = "foo@bar.baz"; + const uuid = "77a7b9d3-e73c-4cf3-97fb-1824868fe00f"; + + const id2 = "foo-2@bar.baz"; + const uuid2 = "89383c45-7db4-4999-83f7-f4cc246372cd"; + + const baseURL = "file:///foo/"; + + const mozExtURL = `moz-extension://${uuid}/`; + const mozExtURL2 = `moz-extension://${uuid2}/`; + + let policy = new WebExtensionPolicy({ + id, + mozExtensionHostname: uuid, + baseURL, + localizeCallback() {}, + allowedOrigins: new MatchPatternSet([]), + permissions: [""], + }); + + let policy2 = new WebExtensionPolicy({ + id: id2, + mozExtensionHostname: uuid2, + baseURL, + localizeCallback() {}, + allowedOrigins: new MatchPatternSet([]), + permissions: [""], + }); + + let script1 = new WebExtensionContentScript(policy, { + run_at: "document_end", + js: [`${mozExtURL}/registered-content-script.js`], + matches: new MatchPatternSet(["http://localhost/data/*"]), + }); + + let script2 = new WebExtensionContentScript(policy, { + run_at: "document_end", + css: [`${mozExtURL}/registered-content-style.css`], + matches: new MatchPatternSet(["http://localhost/data/*"]), + }); + + let script3 = new WebExtensionContentScript(policy2, { + run_at: "document_end", + css: [`${mozExtURL2}/registered-content-style.css`], + matches: new MatchPatternSet(["http://localhost/data/*"]), + }); + + deepEqual( + policy.contentScripts, + [], + "The policy contentScripts is initially empty" + ); + + policy.registerContentScript(script1); + + deepEqual( + policy.contentScripts, + [script1], + "script1 has been added to the policy contentScripts" + ); + + Assert.throws( + () => policy.registerContentScript(script1), + e => e.result == Cr.NS_ERROR_ILLEGAL_VALUE, + "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to register a script more than once" + ); + + Assert.throws( + () => policy.registerContentScript(script3), + e => e.result == Cr.NS_ERROR_ILLEGAL_VALUE, + "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to register a script related to " + + "a different extension" + ); + + Assert.throws( + () => policy.unregisterContentScript(script3), + e => e.result == Cr.NS_ERROR_ILLEGAL_VALUE, + "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to unregister a script related to " + + "a different extension" + ); + + deepEqual( + policy.contentScripts, + [script1], + "script1 has not been added twice" + ); + + policy.registerContentScript(script2); + + deepEqual( + policy.contentScripts, + [script1, script2], + "script2 has the last item of the policy contentScripts array" + ); + + policy.unregisterContentScript(script1); + + deepEqual( + policy.contentScripts, + [script2], + "script1 has been removed from the policy contentscripts" + ); + + Assert.throws( + () => policy.unregisterContentScript(script1), + e => e.result == Cr.NS_ERROR_ILLEGAL_VALUE, + "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to unregister a script more than once" + ); + + deepEqual( + policy.contentScripts, + [script2], + "the policy contentscripts is unmodified when unregistering an unknown contentScript" + ); + + policy.unregisterContentScript(script2); + + deepEqual( + policy.contentScripts, + [], + "script2 has been removed from the policy contentScripts" + ); +}); + +add_task(async function test_WebExtensionPolicy_static_themes_resources() { + const uuid = "0e7ae607-b5b3-4204-9838-c2138c14bc3c"; + const mozExtURL = `moz-extension://${uuid}/`; + const mozExtURI = newURI(mozExtURL); + + let policy = new WebExtensionPolicy({ + id: "test-extension@mochitest", + mozExtensionHostname: uuid, + baseURL: "file:///foo/foo/", + localizeCallback() {}, + allowedOrigins: new MatchPatternSet([]), + permissions: [], + }); + policy.active = true; + + let staticThemePolicy = new WebExtensionPolicy({ + id: "statictheme@bar.baz", + mozExtensionHostname: "164d05dc-b45b-4731-aefc-7c1691bae9a4", + baseURL: "file:///static_theme/", + type: "theme", + allowedOrigins: new MatchPatternSet([]), + localizeCallback() {}, + }); + + staticThemePolicy.active = true; + + ok( + staticThemePolicy.sourceMayAccessPath(mozExtURI, "/someresource.ext"), + "Active extensions should be allowed to access the static themes resources" + ); + + policy.active = false; + + ok( + !staticThemePolicy.sourceMayAccessPath(mozExtURI, "/someresource.ext"), + "Disabled extensions should be disallowed the static themes resources" + ); + + ok( + !staticThemePolicy.sourceMayAccessPath( + Services.io.newURI("http://example.com"), + "/someresource.ext" + ), + "Web content should be disallowed the static themes resources" + ); + + staticThemePolicy.active = false; +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_change_backgroundServiceWorker_enabled_pref_false.js b/toolkit/components/extensions/test/xpcshell/test_change_backgroundServiceWorker_enabled_pref_false.js new file mode 100644 index 0000000000..0be36788c1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_change_backgroundServiceWorker_enabled_pref_false.js @@ -0,0 +1,47 @@ +"use strict"; + +// extensions.backgroundServiceWorker.enabled=false is set in the test manifest +// because there is no guarantee that the pref value set at runtime takes effect +// due to the pref being declared "mirror: once". The value of this pref is +// frozen upon the first access to any "mirror:once" pref, and we can therefore +// not assume the pref value to be mutable at runtime. +const PREF_EXT_SW_ENABLED = "extensions.backgroundServiceWorker.enabled"; + +add_task(async function test_backgroundServiceWorkerEnabled() { + // Sanity check: + Assert.equal( + Services.prefs.getBoolPref(PREF_EXT_SW_ENABLED), + false, + "Pref value should be false" + ); + Assert.equal( + WebExtensionPolicy.backgroundServiceWorkerEnabled, + false, + "backgroundServiceWorkerEnabled should be false" + ); + + if (AppConstants.MOZ_WEBEXT_WEBIDL_ENABLED) { + Assert.ok( + !Services.prefs.prefIsLocked(PREF_EXT_SW_ENABLED), + "Pref should be not locked when MOZ_WEBEXT_WEBIDL_ENABLED is true" + ); + } else { + Assert.ok( + Services.prefs.prefIsLocked(PREF_EXT_SW_ENABLED), + "Pref should be locked when MOZ_WEBEXT_WEBIDL_ENABLED is false" + ); + Services.prefs.unlockPref(PREF_EXT_SW_ENABLED); + } + + // Flip pref and test result. + Services.prefs.setBoolPref(PREF_EXT_SW_ENABLED, true); + Assert.ok( + Services.prefs.getBoolPref(PREF_EXT_SW_ENABLED), + "pref can change after setting it" + ); + Assert.equal( + WebExtensionPolicy.backgroundServiceWorkerEnabled, + false, + "backgroundServiceWorkerEnabled is still false despite the pref flip" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_change_backgroundServiceWorker_enabled_pref_true.js b/toolkit/components/extensions/test/xpcshell/test_change_backgroundServiceWorker_enabled_pref_true.js new file mode 100644 index 0000000000..06286427f5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_change_backgroundServiceWorker_enabled_pref_true.js @@ -0,0 +1,85 @@ +"use strict"; + +// extensions.backgroundServiceWorker.enabled=true is set in the test manifest +// because there is no guarantee that the pref value set at runtime takes effect +// due to the pref being declared "mirror: once". The value of this pref is +// frozen upon the first access to any "mirror:once" pref, and we can therefore +// not assume the pref value to be mutable at runtime. +const PREF_EXT_SW_ENABLED = "extensions.backgroundServiceWorker.enabled"; +const defaultPrefs = Services.prefs.getDefaultBranch(""); + +add_task( + { skip_if: () => AppConstants.MOZ_WEBEXT_WEBIDL_ENABLED }, + async function test_when_extensions_webidl_bindings_disabled() { + Assert.equal( + defaultPrefs.getBoolPref(PREF_EXT_SW_ENABLED), + false, + "The default pref value should be false" + ); + Assert.ok( + Services.prefs.prefIsLocked(PREF_EXT_SW_ENABLED), + "Pref should be locked when MOZ_WEBEXT_WEBIDL_ENABLED is false" + ); + // Despite the pref set to true (see comment at PREF_EXT_SW_ENABLED), the + // pref should be locked to false when IDL is disabled. + Assert.equal( + Services.prefs.getBoolPref(PREF_EXT_SW_ENABLED), + false, + "Pref value should be the default value" + ); + Assert.equal( + WebExtensionPolicy.backgroundServiceWorkerEnabled, + false, + "backgroundServiceWorkerEnabled should be false" + ); + + Services.prefs.unlockPref(PREF_EXT_SW_ENABLED); + + // After unlocking the pref, the pref set to true in the test manifest + // should apply now. + Assert.ok( + Services.prefs.getBoolPref(PREF_EXT_SW_ENABLED), + "After unlocking the pref, the pref can have a non-default value" + ); + Assert.equal( + WebExtensionPolicy.backgroundServiceWorkerEnabled, + false, + "backgroundServiceWorkerEnabled is still false despite the pref flip" + ); + } +); + +add_task( + { skip_if: () => !AppConstants.MOZ_WEBEXT_WEBIDL_ENABLED }, + async function test_when_extensions_webidl_bindings_enabled() { + const defaultPrefValue = defaultPrefs.getBoolPref(PREF_EXT_SW_ENABLED); + info(`The default pref value is ${defaultPrefValue}`); + Assert.ok( + !Services.prefs.prefIsLocked(PREF_EXT_SW_ENABLED), + "Pref should not be locked when MOZ_WEBEXT_WEBIDL_ENABLED is true" + ); + // Note: Pref is set to true, see comment at PREF_EXT_SW_ENABLED. + Assert.equal( + Services.prefs.getBoolPref(PREF_EXT_SW_ENABLED), + true, + "Pref value should be true" + ); + Assert.equal( + WebExtensionPolicy.backgroundServiceWorkerEnabled, + true, + "backgroundServiceWorkerEnabled should be false" + ); + + // Flip pref and test result. + Services.prefs.setBoolPref(PREF_EXT_SW_ENABLED, false); + Assert.ok( + !Services.prefs.getBoolPref(PREF_EXT_SW_ENABLED), + "The pref can be flipped to a different value" + ); + Assert.equal( + WebExtensionPolicy.backgroundServiceWorkerEnabled, + true, + "backgroundServiceWorkerEnabled is still true despite the pref flip" + ); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_change_remote_mode.js b/toolkit/components/extensions/test/xpcshell/test_change_remote_mode.js new file mode 100644 index 0000000000..a6d22e8703 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_change_remote_mode.js @@ -0,0 +1,20 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function change_remote() { + let remote = Services.prefs.getBoolPref("extensions.webextensions.remote"); + Assert.equal( + WebExtensionPolicy.useRemoteWebExtensions, + remote, + "value of useRemoteWebExtensions matches the pref" + ); + + Services.prefs.setBoolPref("extensions.webextensions.remote", !remote); + + Assert.equal( + WebExtensionPolicy.useRemoteWebExtensions, + remote, + "value of useRemoteWebExtensions is still the same after changing the pref" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js b/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js new file mode 100644 index 0000000000..c860d73cc8 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js @@ -0,0 +1,303 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +const ADDON_ID = "test@web.extension"; + +const aps = Cc["@mozilla.org/addons/policy-service;1"].getService( + Ci.nsIAddonPolicyService +); + +const v2_csp = Preferences.get( + "extensions.webextensions.base-content-security-policy" +); +const v3_csp = Preferences.get( + "extensions.webextensions.base-content-security-policy.v3" +); + +add_task(async function test_invalid_addon_csp() { + await Assert.throws( + () => aps.getBaseCSP("invalid@missing"), + /NS_ERROR_ILLEGAL_VALUE/, + "no base csp for non-existent addon" + ); + await Assert.throws( + () => aps.getExtensionPageCSP("invalid@missing"), + /NS_ERROR_ILLEGAL_VALUE/, + "no extension page csp for non-existent addon" + ); +}); + +add_task(async function test_policy_csp() { + equal( + aps.defaultCSP, + Preferences.get("extensions.webextensions.default-content-security-policy"), + "Expected default CSP value" + ); + + const CUSTOM_POLICY = "script-src: 'self' https://xpcshell.test.custom.csp"; + + let tests = [ + { + name: "manifest version 2, no custom policy", + policyData: {}, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest version 2, no custom policy", + policyData: { + manifestVersion: 2, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "version 2 custom extension policy", + policyData: { + extensionPageCSP: CUSTOM_POLICY, + }, + expectedPolicy: CUSTOM_POLICY, + }, + { + name: "manifest version 2 set, custom extension policy", + policyData: { + manifestVersion: 2, + extensionPageCSP: CUSTOM_POLICY, + }, + expectedPolicy: CUSTOM_POLICY, + }, + { + name: "manifest version 3, no custom policy", + policyData: { + manifestVersion: 3, + }, + expectedPolicy: aps.defaultCSPV3, + }, + { + name: "manifest 3 version set, custom extensionPage policy", + policyData: { + manifestVersion: 3, + extensionPageCSP: CUSTOM_POLICY, + }, + expectedPolicy: CUSTOM_POLICY, + }, + ]; + + let policy = null; + + function setExtensionCSP({ manifestVersion, extensionPageCSP }) { + if (policy) { + policy.active = false; + } + + policy = new WebExtensionPolicy({ + id: ADDON_ID, + mozExtensionHostname: ADDON_ID, + baseURL: "file:///", + + allowedOrigins: new MatchPatternSet([]), + localizeCallback() {}, + + manifestVersion, + extensionPageCSP, + }); + + policy.active = true; + } + + for (let test of tests) { + info(test.name); + setExtensionCSP(test.policyData); + equal( + aps.getBaseCSP(ADDON_ID), + test.policyData.manifestVersion == 3 ? v3_csp : v2_csp, + "baseCSP is correct" + ); + equal( + aps.getExtensionPageCSP(ADDON_ID), + test.expectedPolicy, + "extensionPageCSP is correct" + ); + } +}); + +add_task(async function test_extension_csp() { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + + ExtensionTestUtils.failOnSchemaWarnings(false); + + let extension_pages = "script-src 'self'; img-src 'none'"; + + let tests = [ + { + name: "manifest_v2 invalid csp results in default csp used", + manifest: { + content_security_policy: `script-src 'none'`, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest_v2 allows https protocol", + manifest: { + manifest_version: 2, + content_security_policy: `script-src 'self' https://example.com`, + }, + expectedPolicy: `script-src 'self' https://example.com`, + }, + { + name: "manifest_v2 allows unsafe-eval", + manifest: { + manifest_version: 2, + content_security_policy: `script-src 'self' 'unsafe-eval'`, + }, + expectedPolicy: `script-src 'self' 'unsafe-eval'`, + }, + { + name: "manifest_v2 allows wasm-unsafe-eval", + manifest: { + manifest_version: 2, + content_security_policy: `script-src 'self' 'wasm-unsafe-eval'`, + }, + expectedPolicy: `script-src 'self' 'wasm-unsafe-eval'`, + }, + { + // object-src used to require local sources, but now we accept anything. + name: "manifest_v2 allows object-src, with non-local sources", + manifest: { + manifest_version: 2, + content_security_policy: `script-src 'self'; object-src https:'`, + }, + expectedPolicy: `script-src 'self'; object-src https:'`, + }, + { + name: "manifest_v3 invalid csp results in default csp used", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'none'`, + }, + }, + expectedPolicy: aps.defaultCSPV3, + }, + { + name: "manifest_v3 forbidden protocol results in default csp used", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' https://*`, + }, + }, + expectedPolicy: aps.defaultCSPV3, + }, + { + name: "manifest_v3 forbidden eval results in default csp used", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' 'unsafe-eval'`, + }, + }, + expectedPolicy: aps.defaultCSPV3, + }, + { + name: "manifest_v3 disallows localhost", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' https://localhost`, + }, + }, + expectedPolicy: aps.defaultCSPV3, + }, + { + name: "manifest_v3 disallows 127.0.0.1", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' https://127.0.0.1`, + }, + }, + expectedPolicy: aps.defaultCSPV3, + }, + { + name: "manifest_v3 allows wasm-unsafe-eval", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' 'wasm-unsafe-eval'`, + }, + }, + expectedPolicy: `script-src 'self' 'wasm-unsafe-eval'`, + }, + { + // object-src used to require local sources, but now we accept anything. + name: "manifest_v3 allows object-src, with non-local sources", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self'; object-src https:'`, + }, + }, + expectedPolicy: `script-src 'self'; object-src https:'`, + }, + { + name: "manifest_v2 csp", + manifest: { + manifest_version: 2, + content_security_policy: extension_pages, + }, + expectedPolicy: extension_pages, + }, + { + name: "manifest_v2 with no csp, expect default", + manifest: { + manifest_version: 2, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest_v3 used with no csp, expect default", + manifest: { + manifest_version: 3, + }, + expectedPolicy: aps.defaultCSPV3, + }, + { + name: "manifest_v3 syntax used", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages, + }, + }, + expectedPolicy: extension_pages, + }, + ]; + + for (let test of tests) { + info(test.name); + let extension = ExtensionTestUtils.loadExtension({ + manifest: test.manifest, + }); + await extension.startup(); + let policy = WebExtensionPolicy.getByID(extension.id); + equal( + policy.baseCSP, + test.manifest.manifest_version == 3 ? v3_csp : v2_csp, + "baseCSP is correct" + ); + equal( + policy.extensionPageCSP, + test.expectedPolicy, + "extensionPageCSP is correct." + ); + await extension.unload(); + } + + ExtensionTestUtils.failOnSchemaWarnings(true); + + Services.prefs.clearUserPref("extensions.manifestV3.enabled"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_csp_validator.js b/toolkit/components/extensions/test/xpcshell/test_csp_validator.js new file mode 100644 index 0000000000..12ba3f93e9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_csp_validator.js @@ -0,0 +1,322 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const cps = Cc["@mozilla.org/addons/content-policy;1"].getService( + Ci.nsIAddonContentPolicy +); + +add_task(async function test_csp_validator_flags() { + let checkPolicy = (policy, flags, expectedResult, message = null) => { + info(`Checking policy: ${policy}`); + + let result = cps.validateAddonCSP(policy, flags); + equal(result, expectedResult); + }; + + let flags = Ci.nsIAddonContentPolicy; + + checkPolicy( + "default-src 'self'; script-src 'self' http://localhost", + 0, + "\u2018script-src\u2019 directive contains a forbidden http: protocol source", + "localhost disallowed" + ); + checkPolicy( + "default-src 'self'; script-src 'self' http://localhost", + flags.CSP_ALLOW_LOCALHOST, + null, + "localhost allowed" + ); + + checkPolicy( + "default-src 'self'; script-src 'self' 'unsafe-eval'", + 0, + "\u2018script-src\u2019 directive contains a forbidden 'unsafe-eval' keyword", + "eval disallowed" + ); + checkPolicy( + "default-src 'self'; script-src 'self' 'unsafe-eval'", + flags.CSP_ALLOW_EVAL, + null, + "eval allowed" + ); + + checkPolicy( + "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'", + 0, + "\u2018script-src\u2019 directive contains a forbidden 'wasm-unsafe-eval' keyword", + "wasm disallowed" + ); + checkPolicy( + "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'", + flags.CSP_ALLOW_WASM, + null, + "wasm allowed" + ); + checkPolicy( + "default-src 'self'; script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval'", + flags.CSP_ALLOW_EVAL, + null, + "wasm and eval allowed" + ); + + checkPolicy( + "default-src 'self'; script-src 'self' https://example.com", + 0, + "\u2018script-src\u2019 directive contains a forbidden https: protocol source", + "remote disallowed" + ); + checkPolicy( + "default-src 'self'; script-src 'self' https://example.com", + flags.CSP_ALLOW_REMOTE, + null, + "remote allowed" + ); +}); + +add_task(async function test_csp_validator() { + let checkPolicy = (policy, expectedResult, message = null) => { + info(`Checking policy: ${policy}`); + + let result = cps.validateAddonCSP( + policy, + Ci.nsIAddonContentPolicy.CSP_ALLOW_ANY + ); + equal(result, expectedResult); + }; + + checkPolicy("script-src 'self';", null); + + // In the past, object-src was required to be secure and defaulted to 'self'. + // But that is no longer required (see bug 1766881). + checkPolicy("script-src 'self'; object-src 'self';", null); + checkPolicy("script-src 'self'; object-src https:;", null); + + let hash = + "'sha256-NjZhMDQ1YjQ1MjEwMmM1OWQ4NDBlYzA5N2Q1OWQ5NDY3ZTEzYTNmMzRmNjQ5NGU1MzlmZmQzMmMxYmIzNWYxOCAgLQo='"; + + checkPolicy( + `script-src 'self' https://com https://*.example.com moz-extension://09abcdef blob: filesystem: ${hash} 'unsafe-eval'; ` + + `object-src 'self' https://com https://*.example.com moz-extension://09abcdef blob: filesystem: ${hash}`, + null + ); + + checkPolicy( + "", + "Policy is missing a required \u2018script-src\u2019 directive" + ); + + checkPolicy( + "object-src 'none';", + "Policy is missing a required \u2018script-src\u2019 directive" + ); + + checkPolicy( + "default-src 'self' http:", + "Policy is missing a required \u2018script-src\u2019 directive", + "A strict default-src is required as a fallback if script-src is missing" + ); + + checkPolicy( + "default-src 'self' http:; script-src 'self'", + null, + "A valid script-src removes the need for a strict default-src fallback" + ); + + checkPolicy( + "default-src 'self'", + null, + "A valid default-src should count as a valid script-src" + ); + + checkPolicy( + "default-src 'self'; script-src 'self'", + null, + "A valid default-src should count as a valid script-src" + ); + + checkPolicy( + "default-src 'self'; script-src http://example.com", + "\u2018script-src\u2019 directive contains a forbidden http: protocol source", + "A valid default-src should not allow an invalid script-src directive" + ); + + checkPolicy( + "script-src 'none'", + "\u2018script-src\u2019 must include the source 'self'" + ); + + checkPolicy( + "script-src 'self' 'unsafe-inline'", + "\u2018script-src\u2019 directive contains a forbidden 'unsafe-inline' keyword" + ); + + // Localhost is always valid + for (let src of [ + "http://localhost", + "https://localhost", + "http://127.0.0.1", + "https://127.0.0.1", + ]) { + checkPolicy(`script-src 'self' ${src};`, null); + } + + let directives = ["script-src", "worker-src"]; + + for (let [directive, other] of [directives, directives.slice().reverse()]) { + for (let src of ["https://*", "https://*.blogspot.com", "https://*"]) { + checkPolicy( + `${directive} 'self' ${src}; ${other} 'self';`, + `https: wildcard sources in \u2018${directive}\u2019 directives must include at least one non-generic sub-domain (e.g., *.example.com rather than *.com)` + ); + } + + for (let protocol of ["http", "https"]) { + checkPolicy( + `${directive} 'self' ${protocol}:; ${other} 'self';`, + `${protocol}: protocol requires a host in \u2018${directive}\u2019 directives` + ); + } + + checkPolicy( + `${directive} 'self' http://example.com; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden http: protocol source` + ); + + for (let protocol of ["ftp", "meh"]) { + checkPolicy( + `${directive} 'self' ${protocol}:; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden ${protocol}: protocol source` + ); + } + + checkPolicy( + `${directive} 'self' 'nonce-01234'; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden 'nonce-*' keyword` + ); + } +}); + +add_task(async function test_csp_validator_extension_pages() { + let checkPolicy = (policy, expectedResult, message = null) => { + info(`Checking policy: ${policy}`); + + // While Schemas.jsm uses Ci.nsIAddonContentPolicy.CSP_ALLOW_WASM, we don't + // pass that here because we are only verifying that remote scripts are + // blocked here. + let result = cps.validateAddonCSP(policy, 0); + equal(result, expectedResult); + }; + + checkPolicy("script-src 'self';", null); + checkPolicy("script-src 'self'; worker-src 'none'", null); + checkPolicy("script-src 'self'; worker-src 'self'", null); + + // In the past, object-src was required to be secure and defaulted to 'self'. + // But that is no longer required (see bug 1766881). + checkPolicy("script-src 'self'; object-src 'self';", null); + checkPolicy("script-src 'self'; object-src https:;", null); + + let hash = + "'sha256-NjZhMDQ1YjQ1MjEwMmM1OWQ4NDBlYzA5N2Q1OWQ5NDY3ZTEzYTNmMzRmNjQ5NGU1MzlmZmQzMmMxYmIzNWYxOCAgLQo='"; + + checkPolicy( + `script-src 'self' moz-extension://09abcdef blob: filesystem: ${hash}; `, + null + ); + + for (let policy of ["", "script-src-elem 'none';", "worker-src 'none';"]) { + checkPolicy( + policy, + "Policy is missing a required \u2018script-src\u2019 directive" + ); + } + + checkPolicy( + "default-src 'self' http:; script-src 'self'", + null, + "A valid script-src removes the need for a strict default-src fallback" + ); + + checkPolicy( + "default-src 'self'", + null, + "A valid default-src should count as a valid script-src" + ); + + for (let directive of ["script-src", "worker-src"]) { + checkPolicy( + `default-src 'self'; ${directive} 'self'`, + null, + `A valid default-src should count as a valid ${directive}` + ); + checkPolicy( + `default-src 'self'; ${directive} http://example.com`, + `\u2018${directive}\u2019 directive contains a forbidden http: protocol source`, + `A valid default-src should not allow an invalid ${directive} directive` + ); + } + + checkPolicy( + "script-src 'none'", + "\u2018script-src\u2019 must include the source 'self'" + ); + + checkPolicy( + "script-src 'self' 'unsafe-inline';", + "\u2018script-src\u2019 directive contains a forbidden 'unsafe-inline' keyword" + ); + + checkPolicy( + "script-src 'self' 'unsafe-eval';", + "\u2018script-src\u2019 directive contains a forbidden 'unsafe-eval' keyword" + ); + + // Localhost is invalid + for (let src of [ + "http://localhost", + "https://localhost", + "http://127.0.0.1", + "https://127.0.0.1", + ]) { + const protocol = src.split(":")[0]; + checkPolicy( + `script-src 'self' ${src};`, + `\u2018script-src\u2019 directive contains a forbidden ${protocol}: protocol source` + ); + } + + let directives = ["script-src", "worker-src"]; + + for (let [directive, other] of [directives, directives.slice().reverse()]) { + for (let protocol of ["http", "https"]) { + checkPolicy( + `${directive} 'self' ${protocol}:; ${other} 'self';`, + `${protocol}: protocol requires a host in \u2018${directive}\u2019 directives` + ); + } + + checkPolicy( + `${directive} 'self' https://example.com; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden https: protocol source` + ); + + checkPolicy( + `${directive} 'self' http://example.com; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden http: protocol source` + ); + + for (let protocol of ["ftp", "meh"]) { + checkPolicy( + `${directive} 'self' ${protocol}:; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden ${protocol}: protocol source` + ); + } + + checkPolicy( + `${directive} 'self' 'nonce-01234'; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden 'nonce-*' keyword` + ); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_MessageManagerProxy.js b/toolkit/components/extensions/test/xpcshell/test_ext_MessageManagerProxy.js new file mode 100644 index 0000000000..cb6ec47b52 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_MessageManagerProxy.js @@ -0,0 +1,77 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { MessageManagerProxy } = ChromeUtils.importESModule( + "resource://gre/modules/MessageManagerProxy.sys.mjs" +); + +class TestMessageManagerProxy extends MessageManagerProxy { + constructor(contentPage, identifier) { + super(contentPage.browser); + this.identifier = identifier; + this.contentPage = contentPage; + this.deferred = null; + } + + // Registers message listeners. Call dispose() once you've finished. + async setupPingPongListeners() { + await this.contentPage.loadFrameScript(`() => { + this.addMessageListener("test:MessageManagerProxy:Ping", ({data}) => { + this.sendAsyncMessage("test:MessageManagerProxy:Pong", "${this.identifier}:" + data); + }); + }`); + + // Register the listener here instead of during testPingPong, to make sure + // that the listener is correctly registered during the whole test. + this.addMessageListener("test:MessageManagerProxy:Pong", event => { + ok( + this.deferred, + `[${this.identifier}] expected to be waiting for ping-pong` + ); + this.deferred.resolve(event.data); + this.deferred = null; + }); + } + + async testPingPong(description) { + equal(this.deferred, null, "should not be waiting for a message"); + this.deferred = Promise.withResolvers(); + this.sendAsyncMessage("test:MessageManagerProxy:Ping", description); + let result = await this.deferred.promise; + equal(result, `${this.identifier}:${description}`, "Expected ping-pong"); + } +} + +// Tests that MessageManagerProxy continues to proxy messages after docshells +// have been swapped. +add_task(async function test_message_after_swapdocshells() { + let page1 = await ExtensionTestUtils.loadContentPage("about:blank"); + let page2 = await ExtensionTestUtils.loadContentPage("about:blank"); + + let testProxyOne = new TestMessageManagerProxy(page1, "page1"); + let testProxyTwo = new TestMessageManagerProxy(page2, "page2"); + + await testProxyOne.setupPingPongListeners(); + await testProxyTwo.setupPingPongListeners(); + + await testProxyOne.testPingPong("after setup (to 1)"); + await testProxyTwo.testPingPong("after setup (to 2)"); + + page1.browser.swapDocShells(page2.browser); + + await testProxyOne.testPingPong("after docshell swap (to 1)"); + await testProxyTwo.testPingPong("after docshell swap (to 2)"); + + // Swap again to verify that listeners are repeatedly moved when needed. + page1.browser.swapDocShells(page2.browser); + + await testProxyOne.testPingPong("after another docshell swap (to 1)"); + await testProxyTwo.testPingPong("after another docshell swap (to 2)"); + + // Verify that dispose() works regardless of the browser's validity. + await testProxyOne.dispose(); + await page1.close(); + await page2.close(); + await testProxyTwo.dispose(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_activityLog.js b/toolkit/components/extensions/test/xpcshell/test_ext_activityLog.js new file mode 100644 index 0000000000..00173f3a4d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_activityLog.js @@ -0,0 +1,78 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.usePrivilegedSignatures = false; +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_setup(async () => { + await AddonTestUtils.promiseStartupManager(); +}); + +// This test should produce a warning, but still startup +add_task(async function test_api_restricted() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: "activityLog-permission@tests.mozilla.org" }, + }, + permissions: ["activityLog"], + }, + async background() { + browser.test.assertEq( + undefined, + browser.activityLog, + "activityLog is privileged" + ); + }, + useAddonManager: "permanent", + }); + await extension.startup(); + await extension.unload(); +}); + +// This test should produce a error and not startup +add_task( + { + // Some builds (e.g. thunderbird) have experiments enabled by default. + pref_set: [["extensions.experiments.enabled", false]], + }, + async function test_api_restricted_temporary_without_privilege() { + let extension = ExtensionTestUtils.loadExtension({ + temporarilyInstalled: true, + isPrivileged: false, + manifest: { + browser_specific_settings: { + gecko: { id: "activityLog-permission@tests.mozilla.org" }, + }, + permissions: ["activityLog"], + }, + }); + ExtensionTestUtils.failOnSchemaWarnings(false); + let { messages } = await promiseConsoleOutput(async () => { + await Assert.rejects( + extension.startup(), + /Using the privileged permission/, + "Startup failed with privileged permission" + ); + }); + ExtensionTestUtils.failOnSchemaWarnings(true); + AddonTestUtils.checkMessages( + messages, + { + expected: [ + { + message: + /Using the privileged permission 'activityLog' requires a privileged add-on/, + }, + ], + }, + true + ); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_private_field_xrays.js b/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_private_field_xrays.js new file mode 100644 index 0000000000..2d8b02bcd9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_private_field_xrays.js @@ -0,0 +1,160 @@ +"use strict"; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function test_contentscript_private_field_xrays() { + async function contentScript() { + let node = window.document.createElement("div"); + + class Base { + constructor(o) { + return o; + } + } + + class A extends Base { + #x = 5; + static gx(o) { + return o.#x; + } + static sx(o, v) { + o.#x = v; + } + } + + browser.test.log(A.toString()); + + // Stamp node with A's private field. + new A(node); + + browser.test.log("stamped"); + + browser.test.assertEq( + A.gx(node), + 5, + "We should be able to see our expando private field" + ); + browser.test.log("Read"); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /can't access private field or method/, + "Underlying object should not have our private field" + ); + + browser.test.log("threw"); + window.frames[0].document.adoptNode(node); + browser.test.log("adopted"); + browser.test.assertEq( + A.gx(node), + 5, + "Adoption should not change expando private field" + ); + browser.test.log("read"); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /can't access private field or method/, + "Adoption should really not change expandos private fields" + ); + browser.test.log("threw2"); + + // Repeat but now with an object that has a reference from the + // window it's being cloned into. + node = window.document.createElement("div"); + // Stamp node with A's private field. + new A(node); + A.sx(node, 6); + + browser.test.assertEq( + A.gx(node), + 6, + "We should be able to see our expando (2)" + ); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /can't access private field or method/, + "Underlying object should not have exxpando. (2)" + ); + + window.frames[0].wrappedJSObject.incoming = node.wrappedJSObject; + window.frames[0].document.adoptNode(node); + + browser.test.assertEq( + A.gx(node), + 6, + "We should be able to see our expando (3)" + ); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /can't access private field or method/, + "Underlying object should not have exxpando. (3)" + ); + + // Repeat once more, now with an expando that refers to the object itself + node = window.document.createElement("div"); + new A(node); + A.sx(node, node); + + browser.test.assertEq( + A.gx(node), + node, + "We should be able to see our self-referential expando (4)" + ); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /can't access private field or method/, + "Underlying object should not have exxpando. (4)" + ); + + window.frames[0].document.adoptNode(node); + + browser.test.assertEq( + A.gx(node), + node, + "Adoption should not change our self-referential expando (4)" + ); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /can't access private field or method/, + "Adoption should not change underlying object. (4)" + ); + + // And test what happens if we now set document.domain and cause + // wrapper remapping. + let doc = window.frames[0].document; + // eslint-disable-next-line no-self-assign + doc.domain = doc.domain; + + browser.test.notifyPass("privateFieldXRayAdoption"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_toplevel.html"], + js: ["content_script.js"], + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_toplevel.html` + ); + + await extension.awaitFinish("privateFieldXRayAdoption"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_xrays.js b/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_xrays.js new file mode 100644 index 0000000000..9655c157d1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_xrays.js @@ -0,0 +1,129 @@ +"use strict"; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function test_contentscript_xrays() { + async function contentScript() { + let node = window.document.createElement("div"); + node.expando = 5; + + browser.test.assertEq( + node.expando, + 5, + "We should be able to see our expando" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Underlying object should not have our expando" + ); + + window.frames[0].document.adoptNode(node); + browser.test.assertEq( + node.expando, + 5, + "Adoption should not change expandos" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Adoption should really not change expandos" + ); + + // Repeat but now with an object that has a reference from the + // window it's being cloned into. + node = window.document.createElement("div"); + node.expando = 6; + + browser.test.assertEq( + node.expando, + 6, + "We should be able to see our expando (2)" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Underlying object should not have our expando (2)" + ); + + window.frames[0].wrappedJSObject.incoming = node.wrappedJSObject; + + window.frames[0].document.adoptNode(node); + browser.test.assertEq( + node.expando, + 6, + "Adoption should not change expandos (2)" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Adoption should really not change expandos (2)" + ); + + // Repeat once more, now with an expando that refers to the object itself. + node = window.document.createElement("div"); + node.expando = node; + + browser.test.assertEq( + node.expando, + node, + "We should be able to see our self-referential expando (3)" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Underlying object should not have our self-referential expando (3)" + ); + + window.frames[0].document.adoptNode(node); + browser.test.assertEq( + node.expando, + node, + "Adoption should not change self-referential expando (3)" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Adoption should really not change self-referential expando (3)" + ); + + // And test what happens if we now set document.domain and cause + // wrapper remapping. + let doc = window.frames[0].document; + // eslint-disable-next-line no-self-assign + doc.domain = doc.domain; + + browser.test.notifyPass("contentScriptAdoptionWithXrays"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_toplevel.html"], + js: ["content_script.js"], + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_toplevel.html` + ); + + await extension.awaitFinish("contentScriptAdoptionWithXrays"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms.js new file mode 100644 index 0000000000..892a82e2e3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms.js @@ -0,0 +1,346 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +add_task( + { + // TODO(Bug 1725478): remove the skip if once webidl API bindings will be hidden based on permissions. + skip_if: () => ExtensionTestUtils.isInBackgroundServiceWorkerTests(), + }, + async function test_alarm_without_permissions() { + function backgroundScript() { + browser.test.assertTrue( + !browser.alarms, + "alarm API is not available when the alarm permission is not required" + ); + browser.test.notifyPass("alarms_permission"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: [], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarms_permission"); + await extension.unload(); + } +); + +add_task(async function test_alarm_clear_non_matching_name() { + async function backgroundScript() { + let ALARM_NAME = "test_ext_alarms"; + + browser.alarms.create(ALARM_NAME, { when: Date.now() + 2000000 }); + + let wasCleared = await browser.alarms.clear(ALARM_NAME + "1"); + browser.test.assertFalse(wasCleared, "alarm was not cleared"); + + let alarms = await browser.alarms.getAll(); + browser.test.assertEq(1, alarms.length, "alarm was not removed"); + browser.test.notifyPass("alarm-clear"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-clear"); + await extension.unload(); +}); + +add_task(async function test_alarm_get_and_clear_single_argument() { + async function backgroundScript() { + browser.alarms.create({ when: Date.now() + 2000000 }); + + let alarm = await browser.alarms.get(); + browser.test.assertEq("", alarm.name, "expected alarm returned"); + + let wasCleared = await browser.alarms.clear(); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + let alarms = await browser.alarms.getAll(); + browser.test.assertEq(0, alarms.length, "alarm was removed"); + + browser.test.notifyPass("alarm-single-arg"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-single-arg"); + await extension.unload(); +}); + +// This test case covers the behavior of browser.alarms.create when the +// first optional argument (the alarm name) is passed explicitly as null +// or undefined instead of being omitted. +add_task(async function test_alarm_name_arg_null_or_undefined() { + async function backgroundScript(alarmName) { + browser.alarms.create(alarmName, { when: Date.now() + 2000000 }); + + let alarm = await browser.alarms.get(); + browser.test.assertTrue(alarm, "got an alarm"); + browser.test.assertEq("", alarm.name, "expected alarm returned"); + + let wasCleared = await browser.alarms.clear(); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + let alarms = await browser.alarms.getAll(); + browser.test.assertEq(0, alarms.length, "alarm was removed"); + + browser.test.notifyPass("alarm-test-done"); + } + + for (const alarmName of [null, undefined]) { + info(`Test alarm.create with alarm name ${alarmName}`); + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})(${alarmName})`, + manifest: { + permissions: ["alarms"], + }, + }); + await extension.startup(); + await extension.awaitFinish("alarm-test-done"); + await extension.unload(); + } +}); + +add_task(async function test_get_get_all_clear_all_alarms() { + async function backgroundScript() { + const ALARM_NAME = "test_alarm"; + + let suffixes = [0, 1, 2]; + + for (let suffix of suffixes) { + browser.alarms.create(ALARM_NAME + suffix, { + when: Date.now() + (suffix + 1) * 10000, + }); + } + + let alarms = await browser.alarms.getAll(); + browser.test.assertEq( + suffixes.length, + alarms.length, + "expected number of alarms were found" + ); + alarms.forEach((alarm, index) => { + browser.test.assertEq( + ALARM_NAME + index, + alarm.name, + "alarm has the expected name" + ); + }); + + for (let suffix of suffixes) { + let alarm = await browser.alarms.get(ALARM_NAME + suffix); + browser.test.assertEq( + ALARM_NAME + suffix, + alarm.name, + "alarm has the expected name" + ); + browser.test.sendMessage(`get-${suffix}`); + } + + let wasCleared = await browser.alarms.clear(ALARM_NAME + suffixes[0]); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + alarms = await browser.alarms.getAll(); + browser.test.assertEq(2, alarms.length, "alarm was removed"); + + let alarm = await browser.alarms.get(ALARM_NAME + suffixes[0]); + browser.test.assertEq(undefined, alarm, "non-existent alarm is undefined"); + browser.test.sendMessage(`get-invalid`); + + wasCleared = await browser.alarms.clearAll(); + browser.test.assertTrue(wasCleared, "alarms were cleared"); + + alarms = await browser.alarms.getAll(); + browser.test.assertEq(0, alarms.length, "no alarms exist"); + browser.test.sendMessage("clearAll"); + browser.test.sendMessage("clear"); + browser.test.sendMessage("getAll"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await Promise.all([ + extension.startup(), + extension.awaitMessage("getAll"), + extension.awaitMessage("get-0"), + extension.awaitMessage("get-1"), + extension.awaitMessage("get-2"), + extension.awaitMessage("clear"), + extension.awaitMessage("get-invalid"), + extension.awaitMessage("clearAll"), + ]); + await extension.unload(); +}); + +function getAlarmExtension(alarmCreateOptions, extOpts = {}) { + info( + `Test alarms.create fires with options: ${JSON.stringify( + alarmCreateOptions + )}` + ); + + function backgroundScript(createOptions) { + let ALARM_NAME = "test_ext_alarms"; + let timer; + + browser.alarms.onAlarm.addListener(alarm => { + browser.test.assertEq( + ALARM_NAME, + alarm.name, + "alarm has the expected name" + ); + clearTimeout(timer); + browser.test.sendMessage("alarms-create-with-options"); + }); + + browser.alarms.create(ALARM_NAME, createOptions); + + timer = setTimeout(async () => { + browser.test.fail("alarm fired within expected time"); + let wasCleared = await browser.alarms.clear(ALARM_NAME); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + browser.test.sendMessage("alarms-create-with-options"); + }, 10000); + } + + let { persistent, useAddonManager } = extOpts; + return ExtensionTestUtils.loadExtension({ + useAddonManager, + // Pass the alarms.create options to the background page. + background: `(${backgroundScript})(${JSON.stringify(alarmCreateOptions)})`, + manifest: { + permissions: ["alarms"], + background: { persistent }, + }, + }); +} + +async function test_alarm_fires_with_options(alarmCreateOptions) { + let extension = getAlarmExtension(alarmCreateOptions); + + await extension.startup(); + await extension.awaitMessage("alarms-create-with-options"); + + // Defer unloading the extension so the asynchronous event listener + // reply finishes. + await new Promise(resolve => setTimeout(resolve, 0)); + await extension.unload(); +} + +add_task(async function test_alarm_fires() { + Services.prefs.setBoolPref( + "privacy.resistFingerprinting.reduceTimerPrecision.jitter", + false + ); + + await test_alarm_fires_with_options({ delayInMinutes: 0.01 }); + await test_alarm_fires_with_options({ when: Date.now() + 1000 }); + await test_alarm_fires_with_options({ delayInMinutes: -10 }); + await test_alarm_fires_with_options({ when: Date.now() - 1000 }); + + Services.prefs.clearUserPref( + "privacy.resistFingerprinting.reduceTimerPrecision.jitter" + ); +}); + +function trackEvents(wrapper) { + let events = new Map(); + for (let event of ["background-script-event", "start-background-script"]) { + events.set(event, false); + wrapper.extension.once(event, () => events.set(event, true)); + } + return events; +} + +add_task( + { + // TODO(Bug 1748665): remove the skip once background service worker is also + // woken up by persistent listeners. + skip_if: () => ExtensionTestUtils.isInBackgroundServiceWorkerTests(), + pref_set: [ + ["privacy.resistFingerprinting.reduceTimerPrecision.jitter", false], + ["extensions.eventPages.enabled", true], + ], + }, + async function test_alarm_persists() { + await AddonTestUtils.promiseStartupManager(); + + let extension = getAlarmExtension( + { periodInMinutes: 0.01 }, + { useAddonManager: "permanent", persistent: false } + ); + info(`wait startup`); + await extension.startup(); + assertPersistentListeners(extension, "alarms", "onAlarm", { + primed: false, + }); + info(`wait first alarm`); + await extension.awaitMessage("alarms-create-with-options"); + + await extension.terminateBackground({ disableResetIdleForTest: true }); + ok( + !extension.extension.backgroundContext, + "Background Extension context should have been destroyed" + ); + + assertPersistentListeners(extension, "alarms", "onAlarm", { + primed: true, + }); + + // Test an early startup event + let events = trackEvents(extension); + ok( + !events.get("background-script-event"), + "Should not have received a background script event" + ); + ok( + !events.get("start-background-script"), + "Background script should not be started" + ); + + info("waiting for alarm to wake background"); + await extension.awaitMessage("alarms-create-with-options"); + ok( + events.get("background-script-event"), + "Should have received a background script event" + ); + ok( + events.get("start-background-script"), + "Background script should be started" + ); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms_does_not_fire.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_does_not_fire.js new file mode 100644 index 0000000000..fe385004ba --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_does_not_fire.js @@ -0,0 +1,34 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function test_cleared_alarm_does_not_fire() { + async function backgroundScript() { + let ALARM_NAME = "test_ext_alarms"; + + browser.alarms.onAlarm.addListener(alarm => { + browser.test.fail("cleared alarm does not fire"); + browser.test.notifyFail("alarm-cleared"); + }); + browser.alarms.create(ALARM_NAME, { when: Date.now() + 1000 }); + + let wasCleared = await browser.alarms.clear(ALARM_NAME); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + browser.test.notifyPass("alarm-cleared"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-cleared"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms_periodic.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_periodic.js new file mode 100644 index 0000000000..b78d6da649 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_periodic.js @@ -0,0 +1,50 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function test_periodic_alarm_fires() { + function backgroundScript() { + const ALARM_NAME = "test_ext_alarms"; + let count = 0; + let timer; + + browser.alarms.onAlarm.addListener(alarm => { + browser.test.assertEq( + alarm.name, + ALARM_NAME, + "alarm has the expected name" + ); + if (count++ === 3) { + clearTimeout(timer); + browser.alarms.clear(ALARM_NAME).then(wasCleared => { + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + browser.test.notifyPass("alarm-periodic"); + }); + } + }); + + browser.alarms.create(ALARM_NAME, { periodInMinutes: 0.02 }); + + timer = setTimeout(async () => { + browser.test.fail("alarm fired expected number of times"); + + let wasCleared = await browser.alarms.clear(ALARM_NAME); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + browser.test.notifyFail("alarm-periodic"); + }, 30000); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-periodic"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms_replaces.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_replaces.js new file mode 100644 index 0000000000..0d7597fa5a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_replaces.js @@ -0,0 +1,56 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_duplicate_alarm_name_replaces_alarm() { + function backgroundScript() { + let count = 0; + + browser.alarms.onAlarm.addListener(async alarm => { + browser.test.assertEq( + "replaced alarm", + alarm.name, + "Expected last alarm" + ); + browser.test.assertEq( + 0, + count++, + "duplicate named alarm replaced existing alarm" + ); + let results = await browser.alarms.getAll(); + + // "replaced alarm" is expected to be replaced with a non-repeating + // alarm, so it should not appear in the list of alarms. + browser.test.assertEq(1, results.length, "exactly one alarms exists"); + browser.test.assertEq( + "unrelated alarm", + results[0].name, + "remaining alarm has the expected name" + ); + + browser.test.notifyPass("alarm-duplicate"); + }); + + // Alarm that is so far in the future that it is never triggered. + browser.alarms.create("unrelated alarm", { delayInMinutes: 60 }); + // Alarm that repeats. + browser.alarms.create("replaced alarm", { + delayInMinutes: 1 / 60, + periodInMinutes: 1 / 60, + }); + // Before the repeating alarm is triggered, it is immediately replaced with + // a non-repeating alarm. + browser.alarms.create("replaced alarm", { delayInMinutes: 3 / 60 }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-duplicate"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_api_events_listener_calls_exceptions.js b/toolkit/components/extensions/test/xpcshell/test_ext_api_events_listener_calls_exceptions.js new file mode 100644 index 0000000000..44ff592d83 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_api_events_listener_calls_exceptions.js @@ -0,0 +1,369 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ExtensionStorageIDB } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionStorageIDB.sys.mjs" +); + +// Detect if the current build is still using the legacy storage.sync Kinto-based backend +// (currently only GeckoView builds does have that still enabled). +// +// TODO(Bug 1625257): remove this once the rust-based storage.sync backend has been enabled +// also on GeckoView build and the legacy Kinto-based backend has been ripped off. +const storageSyncKintoEnabled = Services.prefs.getBoolPref( + "webextensions.storage.sync.kinto" +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); +server.registerPathHandler("/test-page.html", (req, res) => { + res.setHeader("Content-Type", "text/html", false); + res.write(` + + `); +}); + +add_task(async function test_api_listener_call_exception() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "storage", + "webRequest", + "webRequestBlocking", + "http://example.com/*", + ], + content_scripts: [ + { + js: ["contentscript.js"], + matches: ["http://example.com/test-page.html"], + run_at: "document_start", + }, + ], + }, + files: { + "contentscript.js": () => { + window.onload = () => { + browser.test.assertEq( + window.wrappedJSObject.errorListenerReady, + true, + "Got an onerror listener on the content page side" + ); + browser.test.sendMessage("contentscript-attached"); + }; + // eslint-disable-next-line mozilla/balanced-listeners + window.addEventListener("message", evt => { + browser.test.fail( + `Webpage got notified on an exception raised from the content script: ${JSON.stringify( + evt.data + )}` + ); + }); + // eslint-disable-next-line mozilla/balanced-listeners + window.addEventListener("error", evt => { + const errorDetails = { + message: evt.message, + sourceName: evt.filename, + lineNumber: evt.lineno, + columnNumber: evt.colno, + errorIsDefined: !!evt.error, + }; + browser.test.fail( + `Webpage got notified on an exception raised from the content script: ${JSON.stringify( + errorDetails + )}` + ); + }); + const throwAnError = () => { + throw new Error("test-contentscript-error"); + }; + browser.storage.sync.onChanged.addListener(() => { + throwAnError(); + }); + + browser.storage.local.onChanged.addListener(() => { + throw undefined; // eslint-disable-line no-throw-literal + }); + }, + "extpage.html": ``, + "extpage.js": () => { + // eslint-disable-next-line mozilla/balanced-listeners + window.addEventListener("error", evt => { + browser.test.log( + `Extension page got error event, error property set to: ${evt.error} :: ${evt.error?.stack}\n` + ); + const errorDetails = { + message: evt.message, + sourceName: evt.filename, + lineNumber: evt.lineno, + columnNumber: evt.colno, + errorIsDefined: !!evt.error, + }; + + // Theoretically the exception thrown by a listener registered + // from an extension webpage should be emitting an error event + // (e.g. like for a DOM Event listener in a similar scenario), + // but we never emitted it and so it would be better to only emit + // it after have explicitly accepted the slightly change in behavior. + browser.test.log( + `extension page got notified on an exception raised from the API event listener: ${JSON.stringify( + errorDetails + )}` + ); + }); + browser.webRequest.onBeforeRequest.addListener( + () => { + throw new Error(`Mock webRequest listener exception`); + }, + { urls: ["http://example.com/data/*"] }, + ["blocking"] + ); + + // An object with a custom getter for the `message` property and a custom + // toString method, both are triggering a test failure to make sure we do + // catch with a failure if we are running the extension code as a side effect + // of logging the error to the console service. + const nonError = { + get message() { + browser.test.fail(`Unexpected extension code executed`); + }, + + toString() { + browser.test.fail(`Unexpected extension code executed`); + }, + }; + browser.storage.sync.onChanged.addListener(() => { + throw nonError; + }); + + // Throwing undefined or null is also allowed and so we cover that here as well + // to confirm we are not making any assumption about the value being raised to + // be always defined. + browser.storage.local.onChanged.addListener(() => { + throw undefined; // eslint-disable-line no-throw-literal + }); + }, + }, + }); + + await extension.startup(); + + const page = await ExtensionTestUtils.loadContentPage( + extension.extension.baseURI.resolve("extpage.html"), + { extension } + ); + + // Prepare to collect the error reported for the exception being triggered + // by the test itself. + const prepareWaitForConsoleMessage = () => { + this.content.waitForConsoleMessage = new Promise(resolve => { + const currInnerWindowID = this.content.windowGlobalChild?.innerWindowId; + const consoleListener = { + QueryInterface: ChromeUtils.generateQI(["nsIConsoleListener"]), + observe: message => { + if ( + message instanceof Ci.nsIScriptError && + message.innerWindowID === currInnerWindowID + ) { + resolve({ + message: message.message, + category: message.category, + sourceName: message.sourceName, + hasStack: !!message.stack, + }); + Services.console.unregisterListener(consoleListener); + } + }, + }; + Services.console.registerListener(consoleListener); + }); + }; + + const notifyStorageSyncListener = extensionTestWrapper => { + // The notifyListeners method from ExtensionStorageSyncKinto does use + // the Extension class instance as the key for the storage.sync listeners + // map, whereas ExtensionStorageSync does use the extension id instead. + // + // TODO(Bug 1625257): remove this once the rust-based storage.sync backend has been enabled + // also on GeckoView build and the legacy Kinto-based backend has been ripped off. + let listenersMapKey = storageSyncKintoEnabled + ? extensionTestWrapper.extension + : extensionTestWrapper.id; + ok( + ExtensionParent.apiManager.global.extensionStorageSync.listeners.has( + listenersMapKey + ), + "Got a storage.sync onChanged listener for the test extension" + ); + ExtensionParent.apiManager.global.extensionStorageSync.notifyListeners( + listenersMapKey, + {} + ); + }; + + // Retrieve the message collected from the previously created promise. + const asyncAssertConsoleMessage = async ({ + targetPage, + expectedErrorRegExp, + expectedSourceName, + shouldIncludeStack, + }) => { + const { message, category, sourceName, hasStack } = await targetPage.spawn( + [], + () => this.content.waitForConsoleMessage + ); + + ok( + expectedErrorRegExp.test(message), + `Got the expected error message: ${message}` + ); + + Assert.deepEqual( + { category, sourceName, hasStack }, + { + category: "content javascript", + sourceName: expectedSourceName, + hasStack: shouldIncludeStack, + }, + "Expected category and sourceName are set on the nsIScriptError" + ); + }; + + { + info("Test exception raised by webRequest listener"); + const expectedErrorRegExp = new RegExp( + `Error: Mock webRequest listener exception` + ); + const expectedSourceName = + extension.extension.baseURI.resolve("extpage.js"); + await page.spawn([], prepareWaitForConsoleMessage); + await ExtensionTestUtils.fetch( + "http://example.com", + "http://example.com/data/file_sample.html" + ); + await asyncAssertConsoleMessage({ + targetPage: page, + expectedErrorRegExp, + expectedSourceName, + // TODO(Bug 1810582): this should be expected to be true. + shouldIncludeStack: false, + }); + } + + { + info("Test exception raised by storage.sync listener"); + // The listener has throw an object that isn't an Error instance and + // it also has a getter for the message property, we expect it to be + // logged using the string returned by the native toString method. + const expectedErrorRegExp = new RegExp( + `uncaught exception: \\[object Object\\]` + ); + // TODO(Bug 1810582): this should be expected to be the script url + // where the exception has been originated from. + const expectedSourceName = + extension.extension.baseURI.resolve("extpage.html"); + + await page.spawn([], prepareWaitForConsoleMessage); + notifyStorageSyncListener(extension); + await asyncAssertConsoleMessage({ + targetPage: page, + expectedErrorRegExp, + expectedSourceName, + // TODO(Bug 1810582): this should be expected to be true. + shouldIncludeStack: false, + }); + } + + { + info("Test exception raised by storage.local listener"); + // The listener has throw an object that isn't an Error instance and + // it also has a getter for the message property, we expect it to be + // logged using the string returned by the native toString method. + const expectedErrorRegExp = new RegExp(`uncaught exception: undefined`); + // TODO(Bug 1810582): this should be expected to be the script url + // where the exception has been originated from. + const expectedSourceName = + extension.extension.baseURI.resolve("extpage.html"); + await page.spawn([], prepareWaitForConsoleMessage); + ExtensionStorageIDB.notifyListeners(extension.id, {}); + await asyncAssertConsoleMessage({ + targetPage: page, + expectedErrorRegExp, + expectedSourceName, + // TODO(Bug 1810582): this should be expected to be true. + shouldIncludeStack: false, + }); + } + + await page.close(); + + info("Test content script API event listeners exception"); + + const contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/test-page.html" + ); + + await extension.awaitMessage("contentscript-attached"); + + { + info("Test exception raised by content script storage.sync listener"); + // The listener has throw an object that isn't an Error instance and + // it also has a getter for the message property, we expect it to be + // logged using the string returned by the native toString method. + const expectedErrorRegExp = new RegExp(`Error: test-contentscript-error`); + const expectedSourceName = + extension.extension.baseURI.resolve("contentscript.js"); + + await contentPage.spawn([], prepareWaitForConsoleMessage); + notifyStorageSyncListener(extension); + await asyncAssertConsoleMessage({ + targetPage: contentPage, + expectedErrorRegExp, + expectedSourceName, + // TODO(Bug 1810582): this should be expected to be true. + shouldIncludeStack: false, + }); + } + + { + info("Test exception raised by content script storage.local listener"); + // The listener has throw an object that isn't an Error instance and + // it also has a getter for the message property, we expect it to be + // logged using the string returned by the native toString method. + const expectedErrorRegExp = new RegExp(`uncaught exception: undefined`); + // TODO(Bug 1810582): this should be expected to be the script url + // where the exception has been originated from. + const expectedSourceName = extension.extension.baseURI.resolve("/"); + + await contentPage.spawn([], prepareWaitForConsoleMessage); + ExtensionStorageIDB.notifyListeners(extension.id, {}); + await asyncAssertConsoleMessage({ + targetPage: contentPage, + expectedErrorRegExp, + expectedSourceName, + // TODO(Bug 1810582): this should be expected to be true. + shouldIncludeStack: false, + }); + } + + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js new file mode 100644 index 0000000000..8083f5c920 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js @@ -0,0 +1,75 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Management } = ChromeUtils.importESModule( + "resource://gre/modules/Extension.sys.mjs" +); +function getNextContext() { + return new Promise(resolve => { + Management.on("proxy-context-load", function listener(type, context) { + Management.off("proxy-context-load", listener); + resolve(context); + }); + }); +} + +add_task(async function test_storage_api_without_permissions() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + // Force API initialization. + try { + browser.storage.onChanged.addListener(() => {}); + } catch (e) { + // Ignore. + } + }, + + manifest: { + permissions: [], + }, + }); + + let contextPromise = getNextContext(); + await extension.startup(); + + let context = await contextPromise; + + // Force API initialization. + void context.apiObj; + + ok( + !("storage" in context.apiObj), + "The storage API should not be initialized" + ); + + await extension.unload(); +}); + +add_task(async function test_storage_api_with_permissions() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.storage.onChanged.addListener(() => {}); + }, + + manifest: { + permissions: ["storage"], + }, + }); + + let contextPromise = getNextContext(); + await extension.startup(); + + let context = await contextPromise; + + // Force API initialization. + void context.apiObj; + + equal( + typeof context.apiObj.storage, + "object", + "The storage API should be initialized" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_asyncAPICall_isHandlingUserInput.js b/toolkit/components/extensions/test/xpcshell/test_ext_asyncAPICall_isHandlingUserInput.js new file mode 100644 index 0000000000..73593b7e81 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_asyncAPICall_isHandlingUserInput.js @@ -0,0 +1,149 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ExtensionAPI } = ExtensionCommon; + +const API_CLASS = class extends ExtensionAPI { + getAPI(context) { + return { + testMockAPI: { + async anAsyncAPIMethod(...args) { + const callContextDataBeforeAwait = context.callContextData; + await Promise.resolve(); + const callContextDataAfterAwait = context.callContextData; + return { + args, + callContextDataBeforeAwait, + callContextDataAfterAwait, + }; + }, + }, + }; + } +}; + +const API_SCRIPT = ` + this.testMockAPI = ${API_CLASS.toString()}; +`; + +const API_SCHEMA = [ + { + namespace: "testMockAPI", + functions: [ + { + name: "anAsyncAPIMethod", + type: "function", + async: true, + parameters: [ + { + name: "param1", + type: "object", + additionalProperties: { + type: "string", + }, + }, + { + name: "param2", + type: "string", + }, + ], + }, + ], + }, +]; + +const MODULE_INFO = { + testMockAPI: { + schema: `data:,${JSON.stringify(API_SCHEMA)}`, + scopes: ["addon_parent"], + paths: [["testMockAPI"]], + url: URL.createObjectURL(new Blob([API_SCRIPT])), + }, +}; + +add_setup(async function () { + // The blob:-URL registered above in MODULE_INFO gets loaded at + // https://searchfox.org/mozilla-central/rev/0fec57c05d3996cc00c55a66f20dd5793a9bfb5d/toolkit/components/extensions/ExtensionCommon.jsm#1649 + Services.prefs.setBoolPref( + "security.allow_parent_unrestricted_js_loads", + true + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("security.allow_parent_unrestricted_js_loads"); + }); + + ExtensionParent.apiManager.registerModules(MODULE_INFO); +}); + +add_task( + async function test_propagated_isHandlingUserInput_on_async_api_methods_calls() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "@test-ext" } }, + }, + background() { + browser.test.onMessage.addListener(async (msg, args) => { + if (msg !== "async-method-call") { + browser.test.fail(`Unexpected test message: ${msg}`); + return; + } + + try { + let result = await browser.testMockAPI.anAsyncAPIMethod(...args); + browser.test.sendMessage("async-method-call:result", result); + } catch (err) { + browser.test.sendMessage("async-method-call:error", err.message); + } + }); + }, + }); + + await extension.startup(); + + const callArgs = [{ param1: "param1" }, "param2"]; + + info("Test API method called without handling user input"); + + extension.sendMessage("async-method-call", callArgs); + const result = await extension.awaitMessage("async-method-call:result"); + Assert.deepEqual( + result?.args, + callArgs, + "Got the expected parameters when called without handling user input" + ); + Assert.deepEqual( + result?.callContextDataBeforeAwait, + { isHandlingUserInput: false }, + "Got the expected callContextData before awaiting on a promise" + ); + Assert.deepEqual( + result?.callContextDataAfterAwait, + null, + "context.callContextData should have been nullified after awaiting on a promise" + ); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("async-method-call", callArgs); + const result = await extension.awaitMessage("async-method-call:result"); + Assert.deepEqual( + result?.args, + callArgs, + "Got the expected parameters when called while handling user input" + ); + Assert.deepEqual( + result?.callContextDataBeforeAwait, + { isHandlingUserInput: true }, + "Got the expected callContextData before awaiting on a promise" + ); + Assert.deepEqual( + result?.callContextDataAfterAwait, + null, + "context.callContextData should have been nullified after awaiting on a promise" + ); + }); + + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_api_injection.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_api_injection.js new file mode 100644 index 0000000000..2a0bdba156 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_api_injection.js @@ -0,0 +1,41 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function testBackgroundWindow() { + // Background navigates to http:-URL. + // TODO bug 1286083: Disallow background navigation. + allow_unsafe_parent_loads_when_extensions_not_remote(); + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.log("background script executed"); + window.location = + "http://example.com/data/file_privilege_escalation.html"; + }, + }); + + let awaitConsole = new Promise(resolve => { + Services.console.registerListener(function listener(message) { + if (/WebExt Privilege Escalation/.test(message.message)) { + Services.console.unregisterListener(listener); + resolve(message); + } + }); + }); + + await extension.startup(); + + let message = await awaitConsole; + ok( + message.message.includes( + "WebExt Privilege Escalation: typeof(browser) = undefined" + ), + "Document does not have `browser` APIs." + ); + + await extension.unload(); + + revert_allow_unsafe_parent_loads_when_extensions_not_remote(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_early_shutdown.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_early_shutdown.js new file mode 100644 index 0000000000..706f6d0a67 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_early_shutdown.js @@ -0,0 +1,905 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionTestCommon } = ChromeUtils.importESModule( + "resource://testing-common/ExtensionTestCommon.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +let { promiseRestartManager, promiseShutdownManager, promiseStartupManager } = + AddonTestUtils; + +const { ExtensionProcessCrashObserver, Management } = + ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + +const server = AddonTestUtils.createHttpServer({ + hosts: ["example.com"], +}); +function registerSlowStyleSheet() { + // We can delay DOMContentLoaded of a background page by loading a slow + // stylesheet and using ` + + + + + `, + "background-immediate.js": String.raw` + dump("background-immediate.js is executing as expected.\n"); + if (${!!withContext}) { + // Accessing the browser API triggers context creation. + browser.test.sendMessage("background_started_to_load"); + } + `, + "background-deferred.js": () => { + dump("background-deferred.js is UNEXPECTEDLY executing.\n"); + browser.test.fail("Background startup should have been interrupted"); + }, + }, + }); + let slowStyleSheet = registerSlowStyleSheet(); + await ExtensionTestCommon.resetStartupPromises(); + await extension.startup(); + function assertBackgroundState(expected, message) { + Assert.equal(extension.extension.backgroundState, expected, message); + } + + assertBackgroundState("stopped", "Background should not have started yet"); + + let bgBrowserPromise = new Promise(resolve => { + Management.once("extension-browser-inserted", (eventName, browser) => { + assertBackgroundState("starting", "State when bg is inserted"); + resolve(browser); + }); + }); + + info("Triggering background creation..."); + await ExtensionTestCommon.notifyEarlyStartup(); + await ExtensionTestCommon.notifyLateStartup(); + + let bgBrowser = await bgBrowserPromise; + + if (withContext) { + info("Waiting for background-immediate.js to notify us..."); + await extension.awaitMessage("background_started_to_load"); + Assert.ok( + extension.extension.backgroundContext, + "Context exists when an extension API was called" + ); + // Probably resolved by now, but wait explicitly in case it hasn't, so we + // know that the stylesheet has started to load. + await slowStyleSheet.firstLoadPromise; + } else { + // Wait for the stylesheet request to infer that the background content has + // started to be loaded. + await slowStyleSheet.firstLoadPromise; + Assert.ok( + !extension.extension.backgroundContext, + "Context should not be set while loading" + ); + } + + // Still starting because registerSlowStyleSheet postponed startup completion. + assertBackgroundState("starting", "Background should still be loading"); + + await crashExtensionBackground(extension, bgBrowser); + + assertBackgroundState("stopped", "Background state after crash"); + + // Now that the background is gone, the server can respond without the + // possibility of triggering the execution of background-deferred.js + slowStyleSheet.allowStylesheetToLoad(); + await extension.unload(); + + // Can't be 0 because the background has started to load. + // Can't be 2 because we are loading the background only once. + Assert.equal( + slowStyleSheet.getRequestCount(), + 1, + "Expected exactly one request for slow.css from background page" + ); +} + +add_task( + { + // TODO: consider adding explicit coverage for auto-restart behavior + // when a crash is hit while there is not background context yet. + pref_set: [ + ["extensions.background.disableRestartPersistentAfterCrash", true], + ], + }, + async function test_crash_while_starting_background_without_context() { + await do_test_crash_while_starting_background({ withContext: false }); + } +); + +add_task( + { + // Expected auto-restart behavior is tested in the test task named + // test_persistent_restarted_after_crash. + pref_set: [ + ["extensions.background.disableRestartPersistentAfterCrash", true], + ], + }, + async function test_crash_while_starting_background_with_context() { + await do_test_crash_while_starting_background({ withContext: true }); + } +); + +add_task(async function test_crash_while_starting_event_page_without_context() { + await do_test_crash_while_starting_background({ + withContext: false, + isEventPage: true, + }); +}); + +add_task(async function test_crash_while_starting_event_page_with_context() { + await do_test_crash_while_starting_background({ + withContext: true, + isEventPage: true, + }); +}); + +async function do_test_crash_while_running_background({ isEventPage = false }) { + // wakeupBackground() only wakes up after the early startup notification. + // Trigger explicitly to be independent of other tests. + await ExtensionTestCommon.resetStartupPromises(); + await AddonTestUtils.notifyEarlyStartup(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { persistent: !isEventPage }, + }, + background() { + window.onload = () => { + browser.test.sendMessage("background_has_fully_loaded"); + }; + }, + }); + await extension.startup(); + function assertBackgroundState(expected, message) { + Assert.equal(extension.extension.backgroundState, expected, message); + } + + await extension.awaitMessage("background_has_fully_loaded"); + assertBackgroundState("running", "Background should have started"); + + await crashExtensionBackground(extension); + assertBackgroundState("stopped", "Background state after crash"); + + await extension.wakeupBackground(); + await extension.awaitMessage("background_has_fully_loaded"); + assertBackgroundState("running", "Background resumed after crash recovery"); + await extension.terminateBackground(); + assertBackgroundState("stopped", "Background can sleep after crash recovery"); + + await extension.unload(); +} + +add_task( + { + // Disable auto-restart persistent background pages after a crash, this test + // case is checking that the backgroundState is set to stopped when an + // extension process crash is it but if the background page is restarted + // automatically then the background state will be already set to "starting". + pref_set: [ + ["extensions.background.disableRestartPersistentAfterCrash", true], + ], + }, + async function test_crash_after_background_startup() { + await do_test_crash_while_running_background({ isEventPage: false }); + } +); + +add_task(async function test_crash_after_event_page_startup() { + await do_test_crash_while_running_background({ isEventPage: true }); +}); + +add_task(async function test_crash_and_wakeup_via_persistent_listeners() { + // Ensure that the background can start in response to a primed listener. + await ExtensionTestCommon.resetStartupPromises(); + await AddonTestUtils.notifyEarlyStartup(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { persistent: false }, + optional_permissions: ["tabs"], + }, + background() { + const domreadyPromise = new Promise(resolve => { + window.addEventListener("load", resolve, { once: true }); + }); + browser.permissions.onAdded.addListener(() => { + browser.test.log("permissions.onAdded has fired"); + // Wait for DOMContentLoaded to have fired before notifying the test. + // This guarantees that backgroundState is "running" instead of + // potentially "starting". + domreadyPromise.then(() => { + browser.test.sendMessage("event_fired"); + }); + }); + }, + }); + await extension.startup(); + function assertBackgroundState(expected, message) { + Assert.equal(extension.extension.backgroundState, expected, message); + } + function triggerEventInEventPage() { + // Trigger an event, with the expectation that the event page will wake up. + // As long as we are the only one to trigger the extension API event in this + // test, the exact event is not significant. Trigger permissions.onAdded: + Management.emit("change-permissions", { + extensionId: extension.id, + added: { + origins: [], + permissions: ["tabs"], + }, + }); + } + + assertBackgroundState("running", "Background after extension.startup()"); + + // Sanity check: triggerEventInEventPage does actually trigger event_fired. + triggerEventInEventPage(); + await extension.awaitMessage("event_fired"); + + // Restart a few times to verify that the behavior is consistent over time. + const TEST_RESTART_ATTEMPTS = 5; + for (let i = 1; i <= TEST_RESTART_ATTEMPTS; ++i) { + info(`Testing that a crashed background wakes via event, attempt ${i}/5`); + + await crashExtensionBackground(extension); + + assertBackgroundState("stopped", "Background state after crash"); + + triggerEventInEventPage(); + await extension.awaitMessage("event_fired"); + + assertBackgroundState("running", "Persistent event can wake up event page"); + } + + await extension.unload(); +}); + +add_task( + { + skip_if: () => !CAN_CRASH_EXTENSIONS, + pref_set: [ + ["extensions.webextensions.crash.threshold", 3], + // Set a long timeframe to make sure the few crashes we produce in this + // test will all be counted within the same timeframe. + ["extensions.webextensions.crash.timeframe", 60 * 1000], + ], + }, + async function test_process_spawning_disabled_because_of_too_many_crashes() { + // Force-enable process spawning because that will reset the internals of + // the crash observer. + ExtensionProcessCrashObserver.enableProcessSpawning(); + Services.fog.testResetFOG(); + + function assertCrashThresholdTelemetry({ expectToBeSet }) { + // Desktop builds are only expected to record crashed_over_threshold_fg, + // on Android builds xpcshell tests are detected as being in foreground + // unless we explicitly mock the app being moved in the background as + // the test tasks test_background_restarted_after_crash already does + // (and crashed_over_threshold_bg is covered in that test task). + + equal( + undefined, + Glean.extensions.processEvent.crashed_over_threshold_bg.testGetValue(), + `Initial value of crashed_over_threshold_bg.` + ); + + if (expectToBeSet) { + Assert.greater( + Glean.extensions.processEvent.crashed_over_threshold_fg.testGetValue(), + 0, + "Expect crashed_over_threshold_fg count to be set." + ); + return; + } + + equal( + undefined, + Glean.extensions.processEvent.crashed_over_threshold_fg.testGetValue(), + `Initial value of crashed_over_threshold_fg.` + ); + } + + assertCrashThresholdTelemetry({ expectToBeSet: false }); + + Assert.equal( + ExtensionProcessCrashObserver.processSpawningDisabled, + false, + "Expect process spawning to be enabled" + ); + + // Ensure that the background can start in response to a primed listener. + await ExtensionTestCommon.resetStartupPromises(); + await AddonTestUtils.notifyEarlyStartup(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { persistent: false }, + optional_permissions: ["tabs"], + }, + background() { + const domreadyPromise = new Promise(resolve => { + window.addEventListener("load", resolve, { once: true }); + }); + browser.permissions.onAdded.addListener(() => { + browser.test.log("permissions.onAdded has fired"); + // Wait for DOMContentLoaded to have fired before notifying the test. + // This guarantees that backgroundState is "running" instead of + // potentially "starting". + domreadyPromise.then(() => { + browser.test.sendMessage("event_fired"); + }); + }); + }, + }); + await extension.startup(); + function assertBackgroundState(expected, message) { + Assert.equal(extension.extension.backgroundState, expected, message); + } + function triggerEventInEventPage() { + // Trigger an event, with the expectation that the event page will wake up. + // As long as we are the only one to trigger the extension API event in this + // test, the exact event is not significant. Trigger permissions.onAdded: + Management.emit("change-permissions", { + extensionId: extension.id, + added: { + origins: [], + permissions: ["tabs"], + }, + }); + } + + assertBackgroundState("running", "Background after extension.startup()"); + + // Sanity check: triggerEventInEventPage does actually trigger event_fired. + triggerEventInEventPage(); + await extension.awaitMessage("event_fired"); + + // Crash/restart a few times to force the crash observer to disable process + // spawning on the crash _after_ the loop. Note that the value below should + // match the "threshold" pref set above. + const TEST_RESTART_ATTEMPTS = 3; + for (let i = 1; i <= TEST_RESTART_ATTEMPTS; ++i) { + info( + `Crash/restart extension background, attempt ${i}/${TEST_RESTART_ATTEMPTS}` + ); + + await crashExtensionBackground(extension); + assertBackgroundState("stopped", "Background state after crash"); + + triggerEventInEventPage(); + await extension.awaitMessage("event_fired"); + assertBackgroundState( + "running", + "Persistent event can wake up event page" + ); + } + + assertCrashThresholdTelemetry({ expectToBeSet: false }); + + info("Crash one more time"); + await crashExtensionBackground(extension); + + assertCrashThresholdTelemetry({ expectToBeSet: true }); + + Assert.ok( + ExtensionProcessCrashObserver.processSpawningDisabled, + "Expect process spawning to be disabled" + ); + + info("Trigger an event, which shouldn't wake up the event page"); + triggerEventInEventPage(); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 100)); + assertBackgroundState("stopped", "Background should not have started yet"); + + info("Enable process spawning"); + ExtensionProcessCrashObserver.enableProcessSpawning(); + Assert.equal( + ExtensionProcessCrashObserver.processSpawningDisabled, + false, + "Expect process spawning to be enabled" + ); + assertBackgroundState("stopped", "Background should still be suspended"); + + info("Trigger an event, which should wake up the event page"); + triggerEventInEventPage(); + await extension.awaitMessage("event_fired"); + assertBackgroundState("running", "Persistent event can wake up event page"); + + info("Crash again"); + await crashExtensionBackground(extension); + assertBackgroundState("stopped", "Background state after crash"); + Assert.equal( + ExtensionProcessCrashObserver.processSpawningDisabled, + false, + "Expect process spawning to be enabled" + ); + + info("Trigger an event, which should wake up the event page again"); + triggerEventInEventPage(); + await extension.awaitMessage("event_fired"); + assertBackgroundState("running", "Persistent event can wake up event page"); + + await extension.unload(); + } +); + +add_task( + { + skip_if: () => !CAN_CRASH_EXTENSIONS, + pref_set: [ + ["extensions.webextensions.crash.threshold", 2], + // Set a long timeframe to make sure the few crashes we produce in this + // test will all be counted within the same timeframe. + ["extensions.webextensions.crash.timeframe", 60 * 1000], + ], + }, + async function test_background_restarted_after_crash() { + // Force-enable process spawning because that will reset the internals of + // the crash observer. + ExtensionProcessCrashObserver.enableProcessSpawning(); + Assert.equal( + ExtensionProcessCrashObserver.processSpawningDisabled, + false, + "Expect process spawning to be enabled" + ); + + function assertCrashThresholdTelemetry({ fg, bg }) { + if (fg) { + Assert.greater( + Glean.extensions.processEvent.crashed_over_threshold_fg.testGetValue(), + 0, + "Expect crashed_over_threshold_fg count to be set." + ); + } else { + equal( + undefined, + Glean.extensions.processEvent.crashed_over_threshold_fg.testGetValue(), + `Initial value of crashed_over_threshold_fg.` + ); + } + if (bg) { + Assert.greater( + Glean.extensions.processEvent.crashed_over_threshold_bg.testGetValue(), + 0, + "Expect crashed_over_threshold_bg count to be set." + ); + } else { + equal( + undefined, + Glean.extensions.processEvent.crashed_over_threshold_bg.testGetValue(), + `Initial value of crashed_over_threshold_bg.` + ); + } + } + + // Setup test environment to match a fully started browser instance + await ExtensionTestCommon.resetStartupPromises(); + await AddonTestUtils.notifyEarlyStartup(); + Services.fog.testResetFOG(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { persistent: true }, + }, + background() { + window.addEventListener( + "load", + () => { + browser.test.sendMessage("persistentbg_started"); + }, + { once: true } + ); + }, + }); + + await extension.startup(); + await extension.awaitMessage("persistentbg_started"); + + function assertBackgroundState(expected, message) { + Assert.equal(extension.extension.backgroundState, expected, message); + } + + async function assertStillStoppedAfterTimeout(timeout = 100) { + // Confirm that the state is still stopped and the background page + // was not actually in the process of being restarted. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, timeout)); + assertBackgroundState("stopped", "Background should still be stopped"); + } + + async function mockCrashOnAndroidAppInBackground() { + info("Mock application-background observer service topic"); + ExtensionProcessCrashObserver.observe(null, "application-background"); + + Assert.equal( + ExtensionProcessCrashObserver.appInForeground, + false, + "Got expected value set on ExtensionProcessCrashObserver.appInForeground" + ); + await crashExtensionBackground(extension); + assertBackgroundState( + "stopped", + "Persistent Background state after crash while in the background" + ); + + await assertStillStoppedAfterTimeout(); + + info("Mock application-foreground observer service topic"); + ExtensionProcessCrashObserver.observe(null, "application-foreground"); + + Assert.equal( + ExtensionProcessCrashObserver.appInForeground, + true, + "Ensure the application is detected as being in foreground" + ); + } + + assertBackgroundState("running", "Background after extension.startup()"); + + // Restart a few times to verify that the behavior is consistent over time. + info( + "Testing that a crashed persistent background is restarted after a crash" + ); + + Assert.equal( + ExtensionProcessCrashObserver.appInForeground, + true, + "Ensure the application is detected as being in foreground" + ); + await crashExtensionBackground(extension); + + await extension.awaitMessage("persistentbg_started"); + assertBackgroundState("running", "Persistent Background state after crash"); + + // Mock application moved into the background and background page + // auto-restart to be deferred to the application being moved + // back in the foreground. + if (ExtensionProcessCrashObserver._isAndroid) { + await mockCrashOnAndroidAppInBackground(); + } else { + await crashExtensionBackground(extension); + } + + info("Wait for the persistent background context to be started"); + await extension.awaitMessage("persistentbg_started"); + assertBackgroundState("running", "Persistent Background state after crash"); + + info("Mock another crash to be exceeding enforced crash threshold"); + + assertCrashThresholdTelemetry({ fg: false, bg: false }); + + // Mock application moved into the background and background page + // auto-restart to be deferred to the application being moved + // back in the foreground. + if (ExtensionProcessCrashObserver._isAndroid) { + await mockCrashOnAndroidAppInBackground(); + assertCrashThresholdTelemetry({ fg: false, bg: true }); + } else { + await crashExtensionBackground(extension); + assertCrashThresholdTelemetry({ fg: true, bg: false }); + } + + Assert.ok( + ExtensionProcessCrashObserver.processSpawningDisabled, + "Expect process spawning to be disabled" + ); + + assertBackgroundState( + "stopped", + "Persistent Background state after crash exceeding threshold" + ); + + await assertStillStoppedAfterTimeout(); + + info("Enable process spawning"); + ExtensionProcessCrashObserver.enableProcessSpawning(); + Assert.equal( + ExtensionProcessCrashObserver.processSpawningDisabled, + false, + "Expect process spawning to be enabled" + ); + + info("Wait for Persistent Background Context to be started"); + await extension.awaitMessage("persistentbg_started"); + assertBackgroundState("running", "Persistent Background to be running"); + + // Crash again to confirm the threshold has been reset. + await crashExtensionBackground(extension); + info("Wait for Persistent Background Context to be started"); + await extension.awaitMessage("persistentbg_started"); + assertBackgroundState("running", "Persistent Background to be running"); + + // Crash again to cover explicitly exceeding the crash threshold + // while the application is in foreground. + if (ExtensionProcessCrashObserver._isAndroid) { + Assert.equal( + ExtensionProcessCrashObserver.appInForeground, + true, + "Ensure the application is detected as being in foreground" + ); + + await crashExtensionBackground(extension); + + info("Wait for Persistent Background Context to be started"); + await extension.awaitMessage("persistentbg_started"); + assertBackgroundState("running", "Persistent Background to be running"); + + // Crash one more time to exceed the threshold. + await crashExtensionBackground(extension); + + assertCrashThresholdTelemetry({ fg: true, bg: true }); + + Assert.ok( + ExtensionProcessCrashObserver.processSpawningDisabled, + "Expect process spawning to be disabled" + ); + await assertStillStoppedAfterTimeout(); + } + + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_load_events.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_load_events.js new file mode 100644 index 0000000000..cac574b8ca --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_load_events.js @@ -0,0 +1,23 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* eslint-disable mozilla/balanced-listeners */ + +add_task(async function test_DOMContentLoaded_in_generated_background_page() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + function reportListener(event) { + browser.test.sendMessage("eventname", event.type); + } + document.addEventListener("DOMContentLoaded", reportListener); + window.addEventListener("load", reportListener); + }, + }); + + await extension.startup(); + equal("DOMContentLoaded", await extension.awaitMessage("eventname")); + equal("load", await extension.awaitMessage("eventname")); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_reload.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_reload.js new file mode 100644 index 0000000000..a22db9d582 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_reload.js @@ -0,0 +1,24 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_reload_generated_background_page() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + if (location.hash !== "#firstrun") { + browser.test.sendMessage("first run"); + location.hash = "#firstrun"; + browser.test.assertEq("#firstrun", location.hash); + location.reload(); + } else { + browser.test.notifyPass("second run"); + } + }, + }); + + await extension.startup(); + await extension.awaitMessage("first run"); + await extension.awaitFinish("second run"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_global_history.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_global_history.js new file mode 100644 index 0000000000..19a918eff9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_global_history.js @@ -0,0 +1,24 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { PlacesTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PlacesTestUtils.sys.mjs" +); + +add_task(async function test_global_history() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("background-loaded", location.href); + }, + }); + + await extension.startup(); + + let backgroundURL = await extension.awaitMessage("background-loaded"); + + await extension.unload(); + + let exists = await PlacesTestUtils.isPageInDB(backgroundURL); + ok(!exists, "Background URL should not be in history database"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_iframe.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_iframe.js new file mode 100644 index 0000000000..827a2a8697 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_iframe.js @@ -0,0 +1,349 @@ +"use strict"; + +const { Management } = ChromeUtils.importESModule( + "resource://gre/modules/Extension.sys.mjs" +); + +const server = AddonTestUtils.createHttpServer({ + hosts: ["example.com"], +}); + +// One test below relies on a slow-loading stylesheet. This function and promise +// enables the script to control exactly when the stylesheet load should finish. +let allowStylesheetToLoad; +let stylesheetBlockerPromise = new Promise(resolve => { + allowStylesheetToLoad = resolve; +}); +server.registerPathHandler("/slow.css", (request, response) => { + response.setHeader("Cache-Control", "no-cache", false); + response.setHeader("Content-Type", "text/css", false); + + response.processAsync(); + + stylesheetBlockerPromise.then(() => { + response.write("body { color: rgb(1, 2, 3); }"); + response.finish(); + }); +}); + +// Test helper to keep track of the number of background context loads, from +// any extension. +class BackgroundWatcher { + constructor() { + // Number of background page loads observed. + this.bgBrowserCount = 0; + // Number of top-level background context loads observed. + this.bgViewCount = 0; + this.observing = false; + + this.onBrowserInserted = this.onBrowserInserted.bind(this); + this.onBackgroundViewLoaded = this.onBackgroundViewLoaded.bind(this); + + this.startObserving(); + } + + startObserving() { + this.observing = true; + Management.on("extension-browser-inserted", this.onBrowserInserted); + } + + stopObserving() { + this.observing = false; + Management.off("extension-browser-inserted", this.onBrowserInserted); + // Note: onBrowserInserted adds message listeners to the message manager + // of background contexts, but we do not explicitly unregister these here + // because the caller should only stop observing after knowing for sure + // that there are no new or pending loads of background pages. + } + + onBrowserInserted(eventName, browser) { + Assert.equal(eventName, "extension-browser-inserted", "Seen bg browser"); + if (!browser.getAttribute("webextension-view-type") === "background") { + return; + } + this.bgBrowserCount++; + browser.messageManager.addMessageListener( + "Extension:BackgroundViewLoaded", + this.onBackgroundViewLoaded + ); + } + + onBackgroundViewLoaded({ data }) { + if (!this.observing) { + // We shouldn't receive this event - see comment in stopObserving. + Assert.ok(false, "Got onBackgroundViewLoaded while !observing"); + } + this.bgViewCount++; + Assert.ok(data.childId, "childId passed to Extension:BackgroundViewLoaded"); + } +} + +add_task(async function test_first_extension_api_call_in_iframe() { + // In this test we test what happens when an extension API call happens in + // an iframe before the top-level document observes DOMContentLoaded. + // + // 1. Because DOMContentLoaded is blocked on the execution on + + + + + + `, + "background-subframe.html": ` + + + body_of_iframe`, + "background.js": backgroundScript, + "background-deferred.js": backgroundScriptDeferred, + }, + }); + + const bgWatcher = new BackgroundWatcher(); + // No "await extension.startup();" because extension.startup() in tests is + // currently blocked on background startup (due to TEST_NO_DELAYED_STARTUP + // defaulting to true). Because the background startup completion is blocked + // on the DOMContentLoaded of the background, extension.startup() does not + // resolve until we've unblocked the DOMContentLoaded notification. + const startupPromise = extension.startup(); + await extension.awaitMessage("allowStylesheetToLoad"); + Assert.equal(bgWatcher.bgBrowserCount, 1, "Got background page"); + Assert.equal(bgWatcher.bgViewCount, 0, "Background view still loading"); + info("frame loaded; allowing slow.css to load to unblock DOMContentLoaded"); + allowStylesheetToLoad(); + + info("Waiting for extension.startup() to resolve (background completion)"); + await startupPromise; + info("extension.startup() resolved. Waiting for top_and_frame_done..."); + + await extension.awaitMessage("top_and_frame_done"); + Assert.equal( + extension.extension.backgroundContext?.uri?.spec, + `moz-extension://${extension.uuid}/background.html`, + `extension.backgroundContext should exist and point to the main background` + ); + Assert.equal(bgWatcher.bgViewCount, 1, "Background has loaded once"); + Assert.equal( + extension.extension.views.size, + 2, + "Got ProxyContextParent instances for background and iframe" + ); + + await extension.unload(); + bgWatcher.stopObserving(); +}); + +add_task(async function test_only_script_execution_in_iframe() { + function backgroundSubframeScript() { + // The exact API call does not matter, as any extension API call will + // ensure that ProxyContextParent is initialized if it was not before: + // https://searchfox.org/mozilla-central/rev/892475f3ba2b959aeaef19d1d8602494e3f2ae32/toolkit/components/extensions/ExtensionPageChild.sys.mjs#221,223,227-228 + browser.runtime.getPlatformInfo().then(info => { + browser.test.assertTrue("os" in info, "extension API called in iframe"); + browser.test.assertTrue( + browser.extension.getBackgroundPage() === top, + "extension.getBackgroundPage() returns the top context" + ); + browser.test.sendMessage("iframe_done"); + }); + } + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { + page: "background.html", + }, + }, + files: { + "background.html": ` + + + `, + "background-subframe.html": ` + + + `, + "background-subframe.js": backgroundSubframeScript, + }, + }); + const bgWatcher = new BackgroundWatcher(); + await extension.startup(); + await extension.awaitMessage("iframe_done"); + Assert.equal(bgWatcher.bgBrowserCount, 1, "Got background page"); + Assert.equal(bgWatcher.bgViewCount, 1, "Got background view"); + Assert.equal( + extension.extension.views.size, + 2, + "Got ProxyContextParent instances for background and iframe" + ); + + Assert.equal( + extension.extension.backgroundContext?.uri?.spec, + `moz-extension://${extension.uuid}/background.html`, + `extension.backgroundContext should exist and point to the main background` + ); + + await extension.unload(); + bgWatcher.stopObserving(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js new file mode 100644 index 0000000000..9ce80f3fda --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js @@ -0,0 +1,44 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_background_incognito() { + info( + "Test background page incognito value with permanent private browsing enabled" + ); + + Services.prefs.setBoolPref("browser.privatebrowsing.autostart", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.privatebrowsing.autostart"); + }); + + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + async background() { + browser.test.assertEq( + window, + browser.extension.getBackgroundPage(), + "Caller should be able to access itself as a background page" + ); + browser.test.assertEq( + window, + await browser.runtime.getBackgroundPage(), + "Caller should be able to access itself as a background page" + ); + + browser.test.assertEq( + browser.extension.inIncognitoContext, + true, + "inIncognitoContext is true for permanent private browsing" + ); + + browser.test.notifyPass("incognito"); + }, + }); + + await extension.startup(); + + await extension.awaitFinish("incognito"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_runtime_connect_params.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_runtime_connect_params.js new file mode 100644 index 0000000000..aa0976434b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_runtime_connect_params.js @@ -0,0 +1,88 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function backgroundScript() { + let received_ports_number = 0; + + const expected_received_ports_number = 1; + + function countReceivedPorts(port) { + received_ports_number++; + + if (port.name == "check-results") { + browser.runtime.onConnect.removeListener(countReceivedPorts); + + browser.test.assertEq( + expected_received_ports_number, + received_ports_number, + "invalid connect should not create a port" + ); + + browser.test.notifyPass("runtime.connect invalid params"); + } + } + + browser.runtime.onConnect.addListener(countReceivedPorts); + + let childFrame = document.createElement("iframe"); + childFrame.src = "extensionpage.html"; + document.body.appendChild(childFrame); +} + +function senderScript() { + let detected_invalid_connect_params = 0; + + const invalid_connect_params = [ + // too many params + [ + "fake-extensions-id", + { name: "fake-conn-name" }, + "unexpected third params", + ], + // invalid params format + [{}, {}], + ["fake-extensions-id", "invalid-connect-info-format"], + ]; + const expected_detected_invalid_connect_params = + invalid_connect_params.length; + + function assertInvalidConnectParamsException(params) { + try { + browser.runtime.connect(...params); + } catch (e) { + detected_invalid_connect_params++; + browser.test.assertTrue( + e.toString().includes("Incorrect argument types for runtime.connect."), + "exception message is correct" + ); + } + } + for (let params of invalid_connect_params) { + assertInvalidConnectParamsException(params); + } + browser.test.assertEq( + expected_detected_invalid_connect_params, + detected_invalid_connect_params, + "all invalid runtime.connect params detected" + ); + + browser.runtime.connect(browser.runtime.id, { name: "check-results" }); +} + +let extensionData = { + background: backgroundScript, + files: { + "senderScript.js": senderScript, + "extensionpage.html": ``, + }, +}; + +add_task(async function test_backgroundRuntimeConnectParams() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitFinish("runtime.connect invalid params"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_script_and_service_worker.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_script_and_service_worker.js new file mode 100644 index 0000000000..fc59b1810d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_script_and_service_worker.js @@ -0,0 +1,81 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function testExtensionWithBackground({ + with_scripts = false, + with_service_worker = false, + with_page = false, + expected_background_type, + expected_manifest_warnings = [], +}) { + let background = {}; + if (with_scripts) { + background.scripts = ["scripts.js"]; + } + if (with_service_worker) { + background.service_worker = "sw.js"; + } + if (with_page) { + background.page = "page.html"; + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { background }, + files: { + "scripts.js": () => { + browser.test.sendMessage("from_bg", "scripts"); + }, + "sw.js": () => { + browser.test.sendMessage("from_bg", "service_worker"); + }, + "page.html": ``, + "page.js": () => { + browser.test.sendMessage("from_bg", "page"); + }, + }, + }); + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + Assert.deepEqual( + extension.extension.warnings, + expected_manifest_warnings, + "Expected manifest warnings" + ); + info("Waiting for background to start"); + Assert.equal( + await extension.awaitMessage("from_bg"), + expected_background_type, + "Expected background type" + ); + await extension.unload(); +} + +add_task(async function test_page_and_scripts() { + await testExtensionWithBackground({ + with_page: true, + with_scripts: true, + // Should be expected_background_type: "scripts", not "page". + // https://github.com/w3c/webextensions/issues/282#issuecomment-1443332913 + // ... but changing that may potentially affect backcompat of existing + // Firefox add-ons. + expected_background_type: "page", + expected_manifest_warnings: [ + "Reading manifest: Warning processing background.scripts: An unexpected property was found in the WebExtension manifest.", + ], + }); +}); + +add_task( + { skip_if: () => WebExtensionPolicy.backgroundServiceWorkerEnabled }, + async function test_scripts_and_service_worker_when_sw_disabled() { + await testExtensionWithBackground({ + with_scripts: true, + with_service_worker: true, + expected_background_type: "scripts", + expected_manifest_warnings: [ + "Reading manifest: Warning processing background.service_worker: An unexpected property was found in the WebExtension manifest.", + ], + }); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_service_worker.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_service_worker.js new file mode 100644 index 0000000000..2efbc52739 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_service_worker.js @@ -0,0 +1,321 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); +AddonTestUtils.overrideCertDB(); + +add_task(async function setup() { + ok( + WebExtensionPolicy.useRemoteWebExtensions, + "Expect remote-webextensions mode enabled" + ); + ok( + WebExtensionPolicy.backgroundServiceWorkerEnabled, + "Expect remote-webextensions mode enabled" + ); + + await AddonTestUtils.promiseStartupManager(); + + Services.prefs.setBoolPref("dom.serviceWorkers.testing.enabled", true); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("dom.serviceWorkers.testing.enabled"); + Services.prefs.clearUserPref("dom.serviceWorkers.idle_timeout"); + }); +}); + +add_task( + async function test_fail_spawn_extension_worker_for_disabled_extension() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + version: "1.0", + background: { + service_worker: "sw.js", + }, + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + files: { + "page.html": "", + "sw.js": "dump('Background ServiceWorker - executed\\n');", + }, + }); + + const testWorkerWatcher = new TestWorkerWatcher(); + let watcher = await testWorkerWatcher.watchExtensionServiceWorker( + extension + ); + + await extension.startup(); + + info("Wait for the background service worker to be spawned"); + + ok( + await watcher.promiseWorkerSpawned, + "The extension service worker has been spawned as expected" + ); + + info("Wait for the background service worker to be terminated"); + ok( + await watcher.terminate(), + "The extension service worker has been terminated as expected" + ); + + const swReg = testWorkerWatcher.getRegistration(extension); + ok(swReg, "Got a service worker registration"); + ok(swReg?.activeWorker, "Got an active worker"); + + info("Spawn the active worker by attaching the debugger"); + + watcher = await testWorkerWatcher.watchExtensionServiceWorker(extension); + + swReg.activeWorker.attachDebugger(); + info( + "Wait for the background service worker to be spawned after attaching the debugger" + ); + ok( + await watcher.promiseWorkerSpawned, + "The extension service worker has been spawned as expected" + ); + + swReg.activeWorker.detachDebugger(); + info( + "Wait for the background service worker to be terminated after detaching the debugger" + ); + ok( + await watcher.terminate(), + "The extension service worker has been terminated as expected" + ); + + info( + "Disabling the addon policy, and then double-check that the worker can't be spawned" + ); + const policy = WebExtensionPolicy.getByID(extension.id); + policy.active = false; + + await Assert.throws( + () => swReg.activeWorker.attachDebugger(), + /InvalidStateError/, + "Got the expected extension when trying to spawn a worker for a disabled addon" + ); + + info( + "Enabling the addon policy and double-check the worker is spawned successfully" + ); + policy.active = true; + + watcher = await testWorkerWatcher.watchExtensionServiceWorker(extension); + + swReg.activeWorker.attachDebugger(); + info( + "Wait for the background service worker to be spawned after attaching the debugger" + ); + ok( + await watcher.promiseWorkerSpawned, + "The extension service worker has been spawned as expected" + ); + + swReg.activeWorker.detachDebugger(); + info( + "Wait for the background service worker to be terminated after detaching the debugger" + ); + ok( + await watcher.terminate(), + "The extension service worker has been terminated as expected" + ); + + await testWorkerWatcher.destroy(); + await extension.unload(); + } +); + +add_task(async function test_serviceworker_lifecycle_events() { + async function assertLifecycleEvents({ extension, expected, message }) { + const getLifecycleEvents = async () => { + const { active } = await this.content.navigator.serviceWorker.ready; + const { port1, port2 } = new content.MessageChannel(); + + return new Promise(resolve => { + port1.onmessage = msg => resolve(msg.data.lifecycleEvents); + active.postMessage("test", [port2]); + }); + }; + const page = await ExtensionTestUtils.loadContentPage( + extension.extension.baseURI.resolve("page.html"), + { extension } + ); + Assert.deepEqual( + await page.spawn([], getLifecycleEvents), + expected, + `Got the expected lifecycle events on ${message}` + ); + await page.close(); + } + + const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + background: { + service_worker: "sw.js", + }, + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + files: { + "page.html": "", + "sw.js": ` + dump('Background ServiceWorker - executed\\n'); + + const lifecycleEvents = []; + self.oninstall = () => { + dump('Background ServiceWorker - oninstall\\n'); + lifecycleEvents.push("install"); + }; + self.onactivate = () => { + dump('Background ServiceWorker - onactivate\\n'); + lifecycleEvents.push("activate"); + }; + self.onmessage = (evt) => { + dump('Background ServiceWorker - onmessage\\n'); + evt.ports[0].postMessage({ lifecycleEvents }); + dump('Background ServiceWorker - postMessage\\n'); + }; + `, + }, + }); + + const testWorkerWatcher = new TestWorkerWatcher(); + let watcher = await testWorkerWatcher.watchExtensionServiceWorker(extension); + + await extension.startup(); + + await assertLifecycleEvents({ + extension, + expected: ["install", "activate"], + message: "initial worker registration", + }); + + const file = Services.dirsvc.get("ProfD", Ci.nsIFile); + file.append("serviceworker.txt"); + await TestUtils.waitForCondition( + () => file.exists(), + "Wait for service worker registrations to have been dumped on disk" + ); + + const managerShutdownCompleted = AddonTestUtils.promiseShutdownManager(); + + const firstSwReg = swm.getRegistrationByPrincipal( + extension.extension.principal, + extension.extension.principal.spec + ); + // Force the worker shutdown (in normal condition the worker would have been + // terminated as part of the entire application shutting down). + firstSwReg.forceShutdown(); + + info( + "Wait for the background service worker to be terminated while the app is shutting down" + ); + ok( + await watcher.promiseWorkerTerminated, + "The extension service worker has been terminated as expected" + ); + await managerShutdownCompleted; + + Assert.equal( + firstSwReg, + swm.getRegistrationByPrincipal( + extension.extension.principal, + extension.extension.principal.spec + ), + "Expect the service worker to not be unregistered on application shutdown" + ); + + info("Restart AddonManager (mocking Browser instance restart)"); + // Start the addon manager with `earlyStartup: false` to keep the background service worker + // from being started right away: + // + // - the call to `swm.reloadRegistrationForTest()` that follows is making sure that + // the previously registered service worker is in the same state it would be when + // the entire browser is restarted. + // + // - if the background service worker is being spawned again by the time we call + // `swm.reloadRegistrationForTest()`, ServiceWorkerUpdateJob would fail and trigger + // an `mState == State::Started` diagnostic assertion from ServiceWorkerJob::Finish + // and the xpcshell test will fail for the crash triggered by the assertion. + await AddonTestUtils.promiseStartupManager({ lateStartup: false }); + await extension.awaitStartup(); + + info( + "Force reload ServiceWorkerManager registrations (mocking a Browser instance restart)" + ); + swm.reloadRegistrationsForTest(); + + info( + "trigger delayed call to nsIServiceWorkerManager.registerForAddonPrincipal" + ); + // complete the startup notifications, then start the background + AddonTestUtils.notifyLateStartup(); + extension.extension.emit("start-background-script"); + + info("Force activate the extension worker"); + const newSwReg = swm.getRegistrationByPrincipal( + extension.extension.principal, + extension.extension.principal.spec + ); + + Assert.notEqual( + newSwReg, + firstSwReg, + "Expect the service worker registration to have been recreated" + ); + + await assertLifecycleEvents({ + extension, + expected: [], + message: "on previous registration loaded", + }); + + const { principal } = extension.extension; + const addon = await AddonManager.getAddonByID(extension.id); + await addon.disable(); + + ok( + await watcher.promiseWorkerTerminated, + "The extension service worker has been terminated as expected" + ); + + Assert.throws( + () => swm.getRegistrationByPrincipal(principal, principal.spec), + /NS_ERROR_FAILURE/, + "Expect the service worker to have been unregistered on addon disabled" + ); + + await addon.enable(); + await assertLifecycleEvents({ + extension, + expected: ["install", "activate"], + message: "on disabled addon re-enabled", + }); + + await testWorkerWatcher.destroy(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_sub_windows.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_sub_windows.js new file mode 100644 index 0000000000..1c3180b1b6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_sub_windows.js @@ -0,0 +1,46 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testBackgroundWindow() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.log("background script executed"); + + browser.test.sendMessage("background-script-load"); + + let img = document.createElement("img"); + img.src = + "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; + document.body.appendChild(img); + + img.onload = () => { + browser.test.log("image loaded"); + + let iframe = document.createElement("iframe"); + iframe.src = "about:blank?1"; + + iframe.onload = () => { + browser.test.log("iframe loaded"); + setTimeout(() => { + browser.test.notifyPass("background sub-window test done"); + }, 0); + }; + document.body.appendChild(iframe); + }; + }, + }); + + let loadCount = 0; + extension.onMessage("background-script-load", () => { + loadCount++; + }); + + await extension.startup(); + + await extension.awaitFinish("background sub-window test done"); + + equal(loadCount, 1, "background script loaded only once"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_teardown.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_teardown.js new file mode 100644 index 0000000000..243bc27867 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_teardown.js @@ -0,0 +1,98 @@ +"use strict"; + +add_task(async function test_background_reload_and_unload() { + let events = []; + { + const { Management } = ChromeUtils.importESModule( + "resource://gre/modules/Extension.sys.mjs" + ); + let record = (type, extensionContext) => { + let eventType = type == "proxy-context-load" ? "load" : "unload"; + let url = extensionContext.uri.spec; + let extensionId = extensionContext.extension.id; + events.push({ eventType, url, extensionId }); + }; + + Management.on("proxy-context-load", record); + Management.on("proxy-context-unload", record); + registerCleanupFunction(() => { + Management.off("proxy-context-load", record); + Management.off("proxy-context-unload", record); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.onMessage.addListener(msg => { + browser.test.assertEq("reload-background", msg); + location.reload(); + }); + browser.test.sendMessage("background-url", location.href); + }, + }); + + await extension.startup(); + let backgroundUrl = await extension.awaitMessage("background-url"); + + let contextEvents = events.splice(0); + equal( + contextEvents.length, + 1, + "ExtensionContext state change after loading an extension" + ); + equal(contextEvents[0].eventType, "load"); + equal( + contextEvents[0].url, + backgroundUrl, + "The ExtensionContext should be the background page" + ); + + extension.sendMessage("reload-background"); + await extension.awaitMessage("background-url"); + + contextEvents = events.splice(0); + equal( + contextEvents.length, + 2, + "ExtensionContext state changes after reloading the background page" + ); + equal( + contextEvents[0].eventType, + "unload", + "Unload ExtensionContext of background page" + ); + equal( + contextEvents[0].url, + backgroundUrl, + "ExtensionContext URL = background" + ); + equal( + contextEvents[1].eventType, + "load", + "Create new ExtensionContext for background page" + ); + equal( + contextEvents[1].url, + backgroundUrl, + "ExtensionContext URL = background" + ); + + await extension.unload(); + + contextEvents = events.splice(0); + equal( + contextEvents.length, + 1, + "ExtensionContext state change after unloading the extension" + ); + equal( + contextEvents[0].eventType, + "unload", + "Unload ExtensionContext for background page after extension unloads" + ); + equal( + contextEvents[0].url, + backgroundUrl, + "ExtensionContext URL = background" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js new file mode 100644 index 0000000000..4af16405a2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js @@ -0,0 +1,119 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const HISTOGRAM = "WEBEXT_BACKGROUND_PAGE_LOAD_MS"; +const HISTOGRAM_KEYED = "WEBEXT_BACKGROUND_PAGE_LOAD_MS_BY_ADDONID"; + +add_task(async function test_telemetry() { + const { GleanTimingDistribution } = globalThis; + + let extension1 = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("loaded"); + }, + }); + + let extension2 = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("loaded"); + }, + }); + + resetTelemetryData(); + + assertHistogramEmpty(HISTOGRAM); + assertKeyedHistogramEmpty(HISTOGRAM_KEYED); + assertGleanMetricsNoSamples({ + metricId: "backgroundPageLoad", + gleanMetric: Glean.extensionsTiming.backgroundPageLoad, + gleanMetricConstructor: GleanTimingDistribution, + }); + + await extension1.startup(); + await extension1.awaitMessage("loaded"); + + const processSnapshot = snapshot => { + return snapshot.sum > 0; + }; + + const processKeyedSnapshot = snapshot => { + let res = {}; + for (let key of Object.keys(snapshot)) { + res[key] = snapshot[key].sum > 0; + } + return res; + }; + + assertHistogramSnapshot( + HISTOGRAM, + { processSnapshot, expectedValue: true }, + `Data recorded for first extension for histogram: ${HISTOGRAM}.` + ); + + assertHistogramSnapshot( + HISTOGRAM_KEYED, + { + keyed: true, + processSnapshot: processKeyedSnapshot, + expectedValue: { + [extension1.extension.id]: true, + }, + }, + `Data recorded for first extension for histogram ${HISTOGRAM_KEYED}` + ); + + assertGleanMetricsSamplesCount({ + metricId: "backgroundPageLoad", + gleanMetric: Glean.extensionsTiming.backgroundPageLoad, + gleanMetricConstructor: GleanTimingDistribution, + expectedSamplesCount: 1, + }); + + let histogram = Services.telemetry.getHistogramById(HISTOGRAM); + let histogramKeyed = + Services.telemetry.getKeyedHistogramById(HISTOGRAM_KEYED); + let histogramSum = histogram.snapshot().sum; + let histogramSumExt1 = histogramKeyed.snapshot()[extension1.extension.id].sum; + + await extension2.startup(); + await extension2.awaitMessage("loaded"); + + assertHistogramSnapshot( + HISTOGRAM, + { + processSnapshot: snapshot => snapshot.sum > histogramSum, + expectedValue: true, + }, + `Data recorded for second extension for histogram: ${HISTOGRAM}.` + ); + + assertHistogramSnapshot( + HISTOGRAM_KEYED, + { + keyed: true, + processSnapshot: processKeyedSnapshot, + expectedValue: { + [extension1.extension.id]: true, + [extension2.extension.id]: true, + }, + }, + `Data recorded for second extension for histogram ${HISTOGRAM_KEYED}` + ); + + equal( + histogramKeyed.snapshot()[extension1.extension.id].sum, + histogramSumExt1, + `Data recorder for first extension is unchanged on the keyed histogram ${HISTOGRAM_KEYED}` + ); + + assertGleanMetricsSamplesCount({ + metricId: "backgroundPageLoad", + gleanMetric: Glean.extensionsTiming.backgroundPageLoad, + gleanMetricConstructor: GleanTimingDistribution, + expectedSamplesCount: 2, + }); + + await extension1.unload(); + await extension2.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_type_module.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_type_module.js new file mode 100644 index 0000000000..74512e1e41 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_type_module.js @@ -0,0 +1,133 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +async function assertBackgroundScriptTypes( + extensionTestWrapper, + expectedScriptTypesMap +) { + const { baseURI } = extensionTestWrapper.extension; + let expectedMapWithResolvedURLs = Object.keys(expectedScriptTypesMap).reduce( + (result, scriptPath) => { + result[baseURI.resolve(scriptPath)] = expectedScriptTypesMap[scriptPath]; + return result; + }, + {} + ); + const page = await ExtensionTestUtils.loadContentPage( + baseURI.resolve("_generated_background_page.html") + ); + const scriptTypesMap = await page.spawn([], () => { + const scripts = Array.from( + this.content.document.querySelectorAll("script") + ); + return scripts.reduce((result, script) => { + result[script.getAttribute("src")] = script.getAttribute("type"); + return result; + }, {}); + }); + await page.close(); + Assert.deepEqual( + scriptTypesMap, + expectedMapWithResolvedURLs, + "Got the expected script type from the generated background page" + ); +} + +async function testBackgroundScriptClassic({ manifestTypeClassicSet }) { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { + scripts: ["anotherScript.js", "main.js"], + type: manifestTypeClassicSet ? "classic" : undefined, + }, + }, + files: { + "main.js": ``, + "anotherScript.js": ``, + }, + }); + + await extension.startup(); + await assertBackgroundScriptTypes(extension, { + "main.js": "text/javascript", + "anotherScript.js": "text/javascript", + }); + await extension.unload(); +} + +add_task(async function test_background_scripts_type_default() { + await testBackgroundScriptClassic({ manifestTypeClassicSet: false }); +}); + +add_task(async function test_background_scripts_type_classic() { + await testBackgroundScriptClassic({ manifestTypeClassicSet: true }); +}); + +add_task(async function test_background_scripts_type_module() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { + scripts: ["anotherModule.js", "mainModule.js"], + type: "module", + }, + }, + files: { + "mainModule.js": ` + import { initBackground } from "/importedModule.js"; + browser.test.log("mainModule.js - ESM module executing"); + initBackground(); + `, + "importedModule.js": ` + export function initBackground() { + browser.test.onMessage.addListener((msg) => { + browser.test.log("importedModule.js - test message received"); + browser.test.sendMessage("esm-module-reply", msg); + }); + browser.test.log("importedModule.js - initBackground executed"); + } + browser.test.log("importedModule.js - ESM module loaded"); + `, + "anotherModule.js": ` + browser.test.log("anotherModule.js - ESM module loaded"); + `, + }, + }); + + await extension.startup(); + await extension.sendMessage("test-event-value"); + equal( + await extension.awaitMessage("esm-module-reply"), + "test-event-value", + "Got the expected event from the ESM module loaded from the background script" + ); + await assertBackgroundScriptTypes(extension, { + "mainModule.js": "module", + "anotherModule.js": "module", + }); + await extension.unload(); +}); + +add_task(async function test_background_scripts_type_invalid() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { + scripts: ["anotherScript.js", "main.js"], + type: "invalid", + }, + }, + files: { + "main.js": ``, + "anotherScript.js": ``, + }, + }); + + ExtensionTestUtils.failOnSchemaWarnings(false); + await Assert.rejects( + extension.startup(), + /Error processing background: .* \.type must be one of/, + "Expected install to fail" + ); + ExtensionTestUtils.failOnSchemaWarnings(true); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_window_properties.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_window_properties.js new file mode 100644 index 0000000000..fb2ca27482 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_window_properties.js @@ -0,0 +1,41 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testBackgroundWindowProperties() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + let expectedValues = { + screenX: 0, + screenY: 0, + outerWidth: 0, + outerHeight: 0, + }; + + for (let k in window) { + try { + if (k in expectedValues) { + browser.test.assertEq( + expectedValues[k], + window[k], + `should return the expected value for window property: ${k}` + ); + } else { + void window[k]; + } + } catch (e) { + browser.test.assertEq( + null, + e, + `unexpected exception accessing window property: ${k}` + ); + } + } + + browser.test.notifyPass("background.testWindowProperties.done"); + }, + }); + await extension.startup(); + await extension.awaitFinish("background.testWindowProperties.done"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_brokenlinks.js b/toolkit/components/extensions/test/xpcshell/test_ext_brokenlinks.js new file mode 100644 index 0000000000..c066147268 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_brokenlinks.js @@ -0,0 +1,54 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* + * This test extension has a background script 'missing.js' that is missing + * from the XPI. Such an extension should install/uninstall cleanly without + * causing timeouts. + */ +add_task(async function testXPIMissingBackGroundScript() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { + scripts: ["missing.js"], + }, + }, + }); + + await extension.startup(); + await extension.unload(); + ok(true, "load/unload completed without timing out"); +}); + +/* + * This test extension includes a page with a missing script. The + * extension should install/uninstall cleanly without causing hangs. + */ +add_task(async function testXPIMissingPageScript() { + async function pageScript() { + browser.test.sendMessage("pageReady"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("ready", browser.runtime.getURL("page.html")); + }, + files: { + "page.html": ` + + + `, + "page.js": pageScript, + }, + }); + + await extension.startup(); + let url = await extension.awaitMessage("ready"); + let contentPage = await ExtensionTestUtils.loadContentPage(url); + await extension.awaitMessage("pageReady"); + await extension.unload(); + await contentPage.close(); + + ok(true, "load/unload completed without timing out"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js new file mode 100644 index 0000000000..f1f681b240 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js @@ -0,0 +1,528 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +// The test extension uses an insecure update url. +Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false); + +const SETTINGS_ID = "test_settings_staged_restart_webext@tests.mozilla.org"; + +const { createAppInfo, promiseShutdownManager, promiseStartupManager } = + AddonTestUtils; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42"); + +add_task(async function test_browser_settings() { + const PERM_DENY_ACTION = Services.perms.DENY_ACTION; + const PERM_UNKNOWN_ACTION = Services.perms.UNKNOWN_ACTION; + + // Create an object to hold the values to which we will initialize the prefs. + const PREFS = { + "browser.cache.disk.enable": true, + "browser.cache.memory.enable": true, + "dom.popup_allowed_events": Preferences.get("dom.popup_allowed_events"), + "image.animation_mode": "none", + "permissions.default.desktop-notification": PERM_UNKNOWN_ACTION, + "ui.context_menus.after_mouseup": false, + "browser.tabs.closeTabByDblclick": false, + "browser.tabs.loadBookmarksInTabs": false, + "browser.search.openintab": false, + "browser.tabs.insertRelatedAfterCurrent": true, + "browser.tabs.insertAfterCurrent": false, + "browser.display.document_color_use": 1, + "layout.css.prefers-color-scheme.content-override": 2, + "browser.display.use_document_fonts": 1, + "browser.zoom.full": true, + "browser.zoom.siteSpecific": true, + }; + + async function background() { + let listeners = new Set([]); + browser.test.onMessage.addListener(async (msg, apiName, value) => { + let apiObj = browser.browserSettings; + let apiNameSplit = apiName.split("."); + for (let apiPart of apiNameSplit) { + apiObj = apiObj[apiPart]; + } + if (msg == "get") { + browser.test.sendMessage("settingData", await apiObj.get({})); + return; + } + + // set and setNoOp + + // Don't add more than one listner per apiName. We leave the + // listener to ensure we do not get more calls than we expect. + if (!listeners.has(apiName)) { + apiObj.onChange.addListener(details => { + browser.test.sendMessage("onChange", { + details, + setting: apiName, + }); + }); + listeners.add(apiName); + } + let result = await apiObj.set({ value }); + if (msg === "set") { + browser.test.assertTrue(result, "set returns true."); + browser.test.sendMessage("settingData", await apiObj.get({})); + } else { + browser.test.assertFalse(result, "set returns false for a no-op."); + browser.test.sendMessage("no-op set"); + } + }); + } + + // Set prefs to our initial values. + for (let pref in PREFS) { + Preferences.set(pref, PREFS[pref]); + } + + registerCleanupFunction(() => { + // Reset the prefs. + for (let pref in PREFS) { + Preferences.reset(pref); + } + }); + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browserSettings"], + }, + useAddonManager: "temporary", + }); + + await promiseStartupManager(); + await extension.startup(); + + async function testSetting(setting, value, expected, expectedValue = value) { + extension.sendMessage("set", setting, value); + let data = await extension.awaitMessage("settingData"); + let dataChange = await extension.awaitMessage("onChange"); + equal(setting, dataChange.setting, "onChange fired"); + equal( + data.value, + dataChange.details.value, + "onChange fired with correct value" + ); + deepEqual( + data.value, + expectedValue, + `The ${setting} setting has the expected value.` + ); + equal( + data.levelOfControl, + "controlled_by_this_extension", + `The ${setting} setting has the expected levelOfControl.` + ); + for (let pref in expected) { + equal( + Preferences.get(pref), + expected[pref], + `${pref} set correctly for ${value}` + ); + } + } + + async function testNoOpSetting(setting, value, expected) { + extension.sendMessage("setNoOp", setting, value); + await extension.awaitMessage("no-op set"); + for (let pref in expected) { + equal( + Preferences.get(pref), + expected[pref], + `${pref} set correctly for ${value}` + ); + } + } + + await testSetting("cacheEnabled", false, { + "browser.cache.disk.enable": false, + "browser.cache.memory.enable": false, + }); + await testSetting("cacheEnabled", true, { + "browser.cache.disk.enable": true, + "browser.cache.memory.enable": true, + }); + + await testSetting("allowPopupsForUserEvents", false, { + "dom.popup_allowed_events": "", + }); + await testSetting("allowPopupsForUserEvents", true, { + "dom.popup_allowed_events": PREFS["dom.popup_allowed_events"], + }); + + for (let value of ["normal", "none", "once"]) { + await testSetting("imageAnimationBehavior", value, { + "image.animation_mode": value, + }); + } + + await testSetting("webNotificationsDisabled", true, { + "permissions.default.desktop-notification": PERM_DENY_ACTION, + }); + await testSetting("webNotificationsDisabled", false, { + // This pref is not defaulted on Android. + "permissions.default.desktop-notification": + AppConstants.MOZ_BUILD_APP !== "browser" + ? undefined + : PERM_UNKNOWN_ACTION, + }); + + // This setting is a no-op on Android. + if (AppConstants.platform === "android") { + await testNoOpSetting("contextMenuShowEvent", "mouseup", { + "ui.context_menus.after_mouseup": false, + }); + } else { + await testSetting("contextMenuShowEvent", "mouseup", { + "ui.context_menus.after_mouseup": true, + }); + } + + // "mousedown" is also a no-op on Windows. + if (["android", "win"].includes(AppConstants.platform)) { + await testNoOpSetting("contextMenuShowEvent", "mousedown", { + "ui.context_menus.after_mouseup": AppConstants.platform === "win", + }); + } else { + await testSetting("contextMenuShowEvent", "mousedown", { + "ui.context_menus.after_mouseup": false, + }); + } + + if (AppConstants.platform !== "android") { + await testSetting("closeTabsByDoubleClick", true, { + "browser.tabs.closeTabByDblclick": true, + }); + await testSetting("closeTabsByDoubleClick", false, { + "browser.tabs.closeTabByDblclick": false, + }); + } + + extension.sendMessage("get", "ftpProtocolEnabled"); + let data = await extension.awaitMessage("settingData"); + equal(data.value, false); + equal( + data.levelOfControl, + "not_controllable", + `ftpProtocolEnabled is not controllable.` + ); + + await testSetting("newTabPosition", "afterCurrent", { + "browser.tabs.insertRelatedAfterCurrent": false, + "browser.tabs.insertAfterCurrent": true, + }); + await testSetting("newTabPosition", "atEnd", { + "browser.tabs.insertRelatedAfterCurrent": false, + "browser.tabs.insertAfterCurrent": false, + }); + await testSetting("newTabPosition", "relatedAfterCurrent", { + "browser.tabs.insertRelatedAfterCurrent": true, + "browser.tabs.insertAfterCurrent": false, + }); + + await testSetting("openBookmarksInNewTabs", true, { + "browser.tabs.loadBookmarksInTabs": true, + }); + await testSetting("openBookmarksInNewTabs", false, { + "browser.tabs.loadBookmarksInTabs": false, + }); + + await testSetting("openSearchResultsInNewTabs", true, { + "browser.search.openintab": true, + }); + await testSetting("openSearchResultsInNewTabs", false, { + "browser.search.openintab": false, + }); + + await testSetting("openUrlbarResultsInNewTabs", true, { + "browser.urlbar.openintab": true, + }); + await testSetting("openUrlbarResultsInNewTabs", false, { + "browser.urlbar.openintab": false, + }); + + await testSetting("overrideDocumentColors", "high-contrast-only", { + "browser.display.document_color_use": 0, + }); + await testSetting("overrideDocumentColors", "never", { + "browser.display.document_color_use": 1, + }); + await testSetting("overrideDocumentColors", "always", { + "browser.display.document_color_use": 2, + }); + + await testSetting("overrideContentColorScheme", "dark", { + "layout.css.prefers-color-scheme.content-override": 0, + }); + await testSetting("overrideContentColorScheme", "light", { + "layout.css.prefers-color-scheme.content-override": 1, + }); + await testSetting("overrideContentColorScheme", "auto", { + "layout.css.prefers-color-scheme.content-override": 2, + }); + + await testSetting("useDocumentFonts", false, { + "browser.display.use_document_fonts": 0, + }); + await testSetting("useDocumentFonts", true, { + "browser.display.use_document_fonts": 1, + }); + + await testSetting("zoomFullPage", true, { + "browser.zoom.full": true, + }); + await testSetting("zoomFullPage", false, { + "browser.zoom.full": false, + }); + + await testSetting("zoomSiteSpecific", true, { + "browser.zoom.siteSpecific": true, + }); + await testSetting("zoomSiteSpecific", false, { + "browser.zoom.siteSpecific": false, + }); + + await testSetting("colorManagement.mode", "off", { + "gfx.color_management.mode": 0, + }); + await testSetting("colorManagement.mode", "full", { + "gfx.color_management.mode": 1, + }); + await testSetting("colorManagement.mode", "tagged_only", { + "gfx.color_management.mode": 2, + }); + + await testSetting("colorManagement.useNativeSRGB", false, { + "gfx.color_management.native_srgb": false, + }); + await testSetting("colorManagement.useNativeSRGB", true, { + "gfx.color_management.native_srgb": true, + }); + + await testSetting("colorManagement.useWebRenderCompositor", false, { + "gfx.webrender.compositor": false, + }); + await testSetting("colorManagement.useWebRenderCompositor", true, { + "gfx.webrender.compositor": true, + }); + + await extension.unload(); + await promiseShutdownManager(); +}); + +add_task(async function test_bad_value() { + async function background() { + await browser.test.assertRejects( + browser.browserSettings.contextMenuShowEvent.set({ value: "bad" }), + /bad is not a valid value for contextMenuShowEvent/, + "contextMenuShowEvent.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.overrideDocumentColors.set({ value: 2 }), + /2 is not a valid value for overrideDocumentColors/, + "overrideDocumentColors.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.overrideDocumentColors.set({ value: "bad" }), + /bad is not a valid value for overrideDocumentColors/, + "overrideDocumentColors.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.overrideContentColorScheme.set({ value: 0 }), + /0 is not a valid value for overrideContentColorScheme/, + "overrideContentColorScheme.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.overrideContentColorScheme.set({ value: "bad" }), + /bad is not a valid value for overrideContentColorScheme/, + "overrideContentColorScheme.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.zoomFullPage.set({ value: 0 }), + /0 is not a valid value for zoomFullPage/, + "zoomFullPage.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.zoomFullPage.set({ value: "bad" }), + /bad is not a valid value for zoomFullPage/, + "zoomFullPage.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.zoomSiteSpecific.set({ value: 0 }), + /0 is not a valid value for zoomSiteSpecific/, + "zoomSiteSpecific.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.zoomSiteSpecific.set({ value: "bad" }), + /bad is not a valid value for zoomSiteSpecific/, + "zoomSiteSpecific.set rejects with an invalid value." + ); + + browser.test.sendMessage("done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browserSettings"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_bad_value_android() { + if (AppConstants.platform !== "android") { + return; + } + + async function background() { + await browser.test.assertRejects( + browser.browserSettings.closeTabsByDoubleClick.set({ value: true }), + /android is not a supported platform for the closeTabsByDoubleClick setting/, + "closeTabsByDoubleClick.set rejects on Android." + ); + + await browser.test.assertRejects( + browser.browserSettings.closeTabsByDoubleClick.get({}), + /android is not a supported platform for the closeTabsByDoubleClick setting/, + "closeTabsByDoubleClick.get rejects on Android." + ); + + await browser.test.assertRejects( + browser.browserSettings.closeTabsByDoubleClick.clear({}), + /android is not a supported platform for the closeTabsByDoubleClick setting/, + "closeTabsByDoubleClick.clear rejects on Android." + ); + + browser.test.sendMessage("done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browserSettings"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +// Verifies settings remain after a staged update on restart. +add_task(async function delay_updates_settings_after_restart() { + let server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] }); + AddonTestUtils.registerJSON(server, "/test_update.json", { + addons: { + "test_settings_staged_restart_webext@tests.mozilla.org": { + updates: [ + { + version: "2.0", + update_link: + "http://example.com/addons/test_settings_staged_restart_v2.xpi", + }, + ], + }, + }, + }); + const update_xpi = AddonTestUtils.createTempXPIFile({ + "manifest.json": { + manifest_version: 2, + name: "Delay Upgrade", + version: "2.0", + browser_specific_settings: { + gecko: { id: SETTINGS_ID }, + }, + permissions: ["browserSettings"], + }, + }); + server.registerFile( + `/addons/test_settings_staged_restart_v2.xpi`, + update_xpi + ); + + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: SETTINGS_ID, + update_url: `http://example.com/test_update.json`, + }, + }, + permissions: ["browserSettings"], + }, + background() { + browser.runtime.onUpdateAvailable.addListener(async details => { + if (details) { + await browser.browserSettings.webNotificationsDisabled.set({ + value: true, + }); + if (details.version) { + // This should be the version of the pending update. + browser.test.assertEq("2.0", details.version, "correct version"); + browser.test.notifyPass("delay"); + } + } else { + browser.test.fail("no details object passed"); + } + }); + browser.test.sendMessage("ready"); + }, + }); + + await Promise.all([extension.startup(), extension.awaitMessage("ready")]); + + let prefname = "permissions.default.desktop-notification"; + let val = Services.prefs.getIntPref(prefname); + Assert.notEqual(val, 2, "webNotificationsDisabled pref not set"); + + let update = await AddonTestUtils.promiseFindAddonUpdates(extension.addon); + let install = update.updateAvailable; + Assert.ok(install, `install is available ${update.error}`); + + await AddonTestUtils.promiseCompleteAllInstalls([install]); + + Assert.equal(install.state, AddonManager.STATE_POSTPONED); + await extension.awaitFinish("delay"); + + // restarting allows upgrade to proceed + await AddonTestUtils.promiseRestartManager(); + + await extension.awaitStartup(); + + // If an update is not handled correctly we would fail here. Bug 1639705. + val = Services.prefs.getIntPref(prefname); + Assert.equal(val, 2, "webNotificationsDisabled pref set"); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + + val = Services.prefs.getIntPref(prefname); + Assert.notEqual(val, 2, "webNotificationsDisabled pref not set"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings_homepage.js b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings_homepage.js new file mode 100644 index 0000000000..8d1d16c743 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings_homepage.js @@ -0,0 +1,36 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_homepage_get_without_set() { + async function background() { + let homepage = await browser.browserSettings.homepageOverride.get({}); + browser.test.sendMessage("homepage", homepage); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browserSettings"], + }, + }); + + let defaultHomepage = Services.prefs.getStringPref( + "browser.startup.homepage" + ); + + await extension.startup(); + let homepage = await extension.awaitMessage("homepage"); + equal( + homepage.value, + defaultHomepage, + "The homepageOverride setting has the expected value." + ); + equal( + homepage.levelOfControl, + "not_controllable", + "The homepageOverride setting has the expected levelOfControl." + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browser_style_deprecation.js b/toolkit/components/extensions/test/xpcshell/test_ext_browser_style_deprecation.js new file mode 100644 index 0000000000..ab12181302 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browser_style_deprecation.js @@ -0,0 +1,330 @@ +"use strict"; + +AddonTestUtils.init(this); +// This test expects and checks deprecation warnings. +ExtensionTestUtils.failOnSchemaWarnings(false); + +const PREF_SUPPORTED = "extensions.browser_style_mv3.supported"; +const PREF_SAME_AS_MV2 = "extensions.browser_style_mv3.same_as_mv2"; + +function checkBrowserStyleInManifestKey(extension, key, expected) { + let actual = extension.extension.manifest[key].browser_style; + Assert.strictEqual(actual, expected, `Expected browser_style of "${key}"`); +} + +const BROWSER_STYLE_MV2_DEFAULTS = "BROWSER_STYLE_MV2_DEFAULTS"; +async function checkBrowserStyle({ + manifest_version = 3, + browser_style_in_manifest = null, + expected_browser_style, + expected_warnings, +}) { + const actionKey = manifest_version === 2 ? "browser_action" : "action"; + // sidebar_action is implemented in browser/ and therefore only available to + // Firefox desktop and not other toolkit apps such as Firefox for Android, + // Thunderbird, etc. + const IS_SIDEBAR_SUPPORTED = AppConstants.MOZ_BUILD_APP === "browser"; + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version, + options_ui: { + page: "options.html", + browser_style: browser_style_in_manifest, + }, + [actionKey]: { + browser_style: browser_style_in_manifest, + }, + page_action: { + browser_style: browser_style_in_manifest, + }, + sidebar_action: { + default_panel: "sidebar.html", + browser_style: browser_style_in_manifest, + }, + }, + }); + await extension.startup(); + if (expected_browser_style === BROWSER_STYLE_MV2_DEFAULTS) { + checkBrowserStyleInManifestKey(extension, "options_ui", true); + checkBrowserStyleInManifestKey(extension, actionKey, false); + checkBrowserStyleInManifestKey(extension, "page_action", false); + if (IS_SIDEBAR_SUPPORTED) { + checkBrowserStyleInManifestKey(extension, "sidebar_action", true); + } + } else { + let value = expected_browser_style; + checkBrowserStyleInManifestKey(extension, "options_ui", value); + checkBrowserStyleInManifestKey(extension, actionKey, value); + checkBrowserStyleInManifestKey(extension, "page_action", value); + if (IS_SIDEBAR_SUPPORTED) { + checkBrowserStyleInManifestKey(extension, "sidebar_action", value); + } + } + if (!IS_SIDEBAR_SUPPORTED) { + expected_warnings = expected_warnings.filter( + msg => !msg.includes("sidebar_action") + ); + expected_warnings.unshift( + `Reading manifest: Warning processing sidebar_action: An unexpected property was found in the WebExtension manifest.` + ); + } + const warnings = extension.extension.warnings; + await extension.unload(); + Assert.deepEqual( + warnings, + expected_warnings, + `Got expected warnings for MV${manifest_version} extension with browser_style:${browser_style_in_manifest}.` + ); +} + +async function checkBrowserStyleWithOpenInTabTrue({ + manifest_version = 3, + browser_style_in_manifest = null, + expected_browser_style, +}) { + info( + `Testing options_ui.open_in_tab=true + browser_style=${browser_style_in_manifest} for MV${manifest_version} extension` + ); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version, + options_ui: { + page: "options.html", + browser_style: browser_style_in_manifest, + open_in_tab: true, + }, + }, + }); + await extension.startup(); + checkBrowserStyleInManifestKey( + extension, + "options_ui", + expected_browser_style + ); + const warnings = extension.extension.warnings; + await extension.unload(); + Assert.deepEqual( + warnings, + [], + "Expected no warnings on extension with options_ui.open_in_tab true" + ); +} + +async function repeatTestIndependentOfPref_browser_style_same_as_mv2(testFn) { + for (let same_as_mv2 of [true, false]) { + await runWithPrefs([[PREF_SAME_AS_MV2, same_as_mv2]], testFn); + } +} +async function repeatTestIndependentOf_browser_style_deprecation_prefs(testFn) { + for (let supported of [true, false]) { + for (let same_as_mv2 of [true, false]) { + await runWithPrefs( + [ + [PREF_SUPPORTED, supported], + [PREF_SAME_AS_MV2, same_as_mv2], + ], + testFn + ); + } + } +} + +add_task(async function browser_style_never_deprecated_in_MV2() { + async function check_browser_style_never_deprecated_in_MV2() { + await checkBrowserStyle({ + manifest_version: 2, + browser_style_in_manifest: true, + expected_browser_style: true, + expected_warnings: [], + }); + await checkBrowserStyle({ + manifest_version: 2, + browser_style_in_manifest: false, + expected_browser_style: false, + expected_warnings: [], + }); + await checkBrowserStyle({ + manifest_version: 2, + browser_style_in_manifest: null, + expected_browser_style: "BROWSER_STYLE_MV2_DEFAULTS", + expected_warnings: [], + }); + + // When open_in_tab is true, browser_style is not used and its value does + // not matter. Since we want the parsed value to be false in MV3, and the + // implementation is simpler if consistently applied to MV2, browser_style + // is false when open_in_tab is true (even if browser_style:true is set). + await checkBrowserStyleWithOpenInTabTrue({ + manifest_version: 2, + browser_style_in_manifest: null, + expected_browser_style: false, + }); + await checkBrowserStyleWithOpenInTabTrue({ + manifest_version: 2, + browser_style_in_manifest: true, + expected_browser_style: false, + }); + } + // Regardless of all potential test configurations, browser_style is never + // deprecated in MV2. + await repeatTestIndependentOf_browser_style_deprecation_prefs( + check_browser_style_never_deprecated_in_MV2 + ); +}); + +add_task(async function open_in_tab_implies_browser_style_false_MV3() { + // Regardless of all potential test configurations, when + // options_ui.open_in_tab is true, options_ui.browser_style should be false, + // because it being true would print deprecation warnings in MV3, and + // browser_style:true does not have any effect when open_in_tab is true. + await repeatTestIndependentOf_browser_style_deprecation_prefs(async () => { + await checkBrowserStyleWithOpenInTabTrue({ + manifest_version: 3, + browser_style_in_manifest: null, + expected_browser_style: false, + }); + await checkBrowserStyleWithOpenInTabTrue({ + manifest_version: 3, + browser_style_in_manifest: true, + expected_browser_style: false, + }); + }); +}); + +// Disable browser_style:true - bug 1830711. +add_task(async function unsupported_and_browser_style_true() { + await checkBrowserStyle({ + manifest_version: 3, + browser_style_in_manifest: true, + expected_browser_style: false, + expected_warnings: [ + // TODO bug 1830712: Update warnings when max_manifest_version:2 is used. + `Reading manifest: Warning processing options_ui.browser_style: "browser_style:true" is no longer supported in Manifest Version 3.`, + `Reading manifest: Warning processing action.browser_style: "browser_style:true" is no longer supported in Manifest Version 3.`, + `Reading manifest: Warning processing page_action.browser_style: "browser_style:true" is no longer supported in Manifest Version 3.`, + `Reading manifest: Warning processing sidebar_action.browser_style: "browser_style:true" is no longer supported in Manifest Version 3.`, + ], + }); +}); + +add_task(async function unsupported_and_browser_style_false() { + await checkBrowserStyle({ + manifest_version: 3, + browser_style_in_manifest: false, + expected_browser_style: false, + // TODO bug 1830712: Add warnings when max_manifest_version:2 is used. + expected_warnings: [], + }); +}); + +add_task(async function unsupported_and_browser_style_default() { + await checkBrowserStyle({ + manifest_version: 3, + browser_style_in_manifest: null, + expected_browser_style: false, + expected_warnings: [], + }); +}); + +add_task( + { pref_set: [[PREF_SUPPORTED, true]] }, + async function supported_with_browser_style_true() { + await repeatTestIndependentOfPref_browser_style_same_as_mv2(async () => { + await checkBrowserStyle({ + manifest_version: 3, + browser_style_in_manifest: true, + expected_browser_style: true, + expected_warnings: [ + `Reading manifest: Warning processing options_ui.browser_style: "browser_style:true" has been deprecated in Manifest Version 3 and will be unsupported in the near future.`, + `Reading manifest: Warning processing action.browser_style: "browser_style:true" has been deprecated in Manifest Version 3 and will be unsupported in the near future.`, + `Reading manifest: Warning processing page_action.browser_style: "browser_style:true" has been deprecated in Manifest Version 3 and will be unsupported in the near future.`, + `Reading manifest: Warning processing sidebar_action.browser_style: "browser_style:true" has been deprecated in Manifest Version 3 and will be unsupported in the near future.`, + ], + }); + }); + } +); + +add_task( + { pref_set: [[PREF_SUPPORTED, true]] }, + async function supported_with_browser_style_false() { + await repeatTestIndependentOfPref_browser_style_same_as_mv2(async () => { + await checkBrowserStyle({ + manifest_version: 3, + browser_style_in_manifest: false, + expected_browser_style: false, + expected_warnings: [], + }); + }); + } +); + +// Initial prefs - warn only - https://bugzilla.mozilla.org/show_bug.cgi?id=1827910#c1 +add_task( + { + pref_set: [ + [PREF_SUPPORTED, true], + [PREF_SAME_AS_MV2, true], + ], + }, + async function supported_with_mv2_defaults() { + const makeWarning = manifestKey => + `Reading manifest: Warning processing ${manifestKey}.browser_style: "browser_style:true" has been deprecated in Manifest Version 3 and will be unsupported in the near future. While "${manifestKey}.browser_style" was not explicitly specified in manifest.json, its default value was true. Its default will change to false in Manifest Version 3 starting from Firefox 115.`; + await checkBrowserStyle({ + manifest_version: 3, + browser_style_in_manifest: null, + expected_browser_style: "BROWSER_STYLE_MV2_DEFAULTS", + expected_warnings: [ + makeWarning("options_ui"), + makeWarning("sidebar_action"), + ], + }); + } +); + +// Deprecation + change defaults - bug 1830710. +add_task( + { + pref_set: [ + [PREF_SUPPORTED, true], + [PREF_SAME_AS_MV2, false], + ], + }, + async function supported_with_browser_style_default_false() { + const makeWarning = manifestKey => + `Reading manifest: Warning processing ${manifestKey}.browser_style: "browser_style:true" has been deprecated in Manifest Version 3 and will be unsupported in the near future. While "${manifestKey}.browser_style" was not explicitly specified in manifest.json, its default value was true. The default value of "${manifestKey}.browser_style" has changed from true to false in Manifest Version 3.`; + await checkBrowserStyle({ + manifest_version: 3, + browser_style_in_manifest: null, + expected_browser_style: false, + expected_warnings: [ + makeWarning("options_ui"), + makeWarning("sidebar_action"), + ], + }); + } +); + +// While we are not planning to set this pref combination, users can do so if +// they desire. +add_task( + { + pref_set: [ + [PREF_SUPPORTED, false], + [PREF_SAME_AS_MV2, true], + ], + }, + async function unsupported_with_mv2_defaults() { + const makeWarning = manifestKey => + `Reading manifest: Warning processing ${manifestKey}.browser_style: "browser_style:true" is no longer supported in Manifest Version 3. While "${manifestKey}.browser_style" was not explicitly specified in manifest.json, its default value was true. Its default will change to false in Manifest Version 3 starting from Firefox 115.`; + await checkBrowserStyle({ + manifest_version: 3, + browser_style_in_manifest: null, + expected_browser_style: false, + expected_warnings: [ + makeWarning("options_ui"), + makeWarning("sidebar_action"), + ], + }); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browsingData.js b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData.js new file mode 100644 index 0000000000..1df5e60478 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData.js @@ -0,0 +1,48 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testInvalidArguments() { + async function background() { + const UNSUPPORTED_DATA_TYPES = ["appcache", "fileSystems", "webSQL"]; + + await browser.test.assertRejects( + browser.browsingData.remove( + { originTypes: { protectedWeb: true } }, + { cookies: true } + ), + "Firefox does not support protectedWeb or extension as originTypes.", + "Expected error received when using protectedWeb originType." + ); + + await browser.test.assertRejects( + browser.browsingData.removeCookies({ originTypes: { extension: true } }), + "Firefox does not support protectedWeb or extension as originTypes.", + "Expected error received when using extension originType." + ); + + for (let dataType of UNSUPPORTED_DATA_TYPES) { + let dataTypes = {}; + dataTypes[dataType] = true; + browser.test.assertThrows( + () => browser.browsingData.remove({}, dataTypes), + /Type error for parameter dataToRemove/, + `Expected error received when using ${dataType} dataType.` + ); + } + + browser.test.notifyPass("invalidArguments"); + } + + let extensionData = { + background: background, + manifest: { + permissions: ["browsingData"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("invalidArguments"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cache.js b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cache.js new file mode 100644 index 0000000000..577d727a49 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cache.js @@ -0,0 +1,456 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +const { SiteDataTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/SiteDataTestUtils.sys.mjs" +); + +const COOKIE = { + host: "example.com", + name: "test_cookie", + path: "/", +}; +const COOKIE_NET = { + host: "example.net", + name: "test_cookie", + path: "/", +}; +const COOKIE_ORG = { + host: "example.org", + name: "test_cookie", + path: "/", +}; +let since, oldCookie; + +function addCookie(cookie) { + Services.cookies.add( + cookie.host, + cookie.path, + cookie.name, + "test", + false, + false, + false, + Date.now() / 1000 + 10000, + {}, + Ci.nsICookie.SAMESITE_NONE, + Ci.nsICookie.SCHEME_HTTPS + ); + ok( + Services.cookies.cookieExists(cookie.host, cookie.path, cookie.name, {}), + `Cookie ${cookie.name} was created.` + ); +} + +async function setUpCookies() { + Services.cookies.removeAll(); + + // Add a cookie which will end up with an older creationTime. + oldCookie = Object.assign({}, COOKIE, { name: Date.now() }); + addCookie(oldCookie); + await new Promise(resolve => setTimeout(resolve, 10)); + since = Date.now(); + await new Promise(resolve => setTimeout(resolve, 10)); + + // Add a cookie which will end up with a more recent creationTime. + addCookie(COOKIE); + + // Add cookies for different domains. + addCookie(COOKIE_NET); + addCookie(COOKIE_ORG); +} + +async function setUpCache() { + Services.cache2.clear(); + + // Add cache entries for different domains. + for (const domain of ["example.net", "example.org", "example.com"]) { + await SiteDataTestUtils.addCacheEntry(`http://${domain}/`, "disk"); + await SiteDataTestUtils.addCacheEntry(`http://${domain}/`, "memory"); + } +} + +function hasCacheEntry(domain) { + const disk = SiteDataTestUtils.hasCacheEntry(`http://${domain}/`, "disk"); + const memory = SiteDataTestUtils.hasCacheEntry(`http://${domain}/`, "memory"); + + equal( + disk, + memory, + `For ${domain} either either both or neither caches need to exists.` + ); + return disk; +} + +add_task(async function testCache() { + function background() { + browser.test.onMessage.addListener(async msg => { + if (msg == "removeCache") { + await browser.browsingData.removeCache({}); + } else { + await browser.browsingData.remove({}, { cache: true }); + } + browser.test.sendMessage("cacheRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + async function testRemovalMethod(method) { + await setUpCache(); + + extension.sendMessage(method); + await extension.awaitMessage("cacheRemoved"); + + ok(!hasCacheEntry("example.net"), "example.net cache was removed"); + ok(!hasCacheEntry("example.org"), "example.org cache was removed"); + ok(!hasCacheEntry("example.com"), "example.com cache was removed"); + } + + await extension.startup(); + + await testRemovalMethod("removeCache"); + await testRemovalMethod("remove"); + + await extension.unload(); +}); + +add_task(async function testCookies() { + // Above in setUpCookies we create an 'old' cookies, wait 10ms, then log a timestamp. + // Here we ask the browser to delete all cookies after the timestamp, with the intention + // that the 'old' cookie is not removed. The issue arises when the timer precision is + // low enough such that the timestamp that gets logged is the same as the 'old' cookie. + // We hardcode a precision value to ensure that there is time between the 'old' cookie + // and the timestamp generation. + Services.prefs.setBoolPref("privacy.reduceTimerPrecision", true); + Services.prefs.setIntPref( + "privacy.resistFingerprinting.reduceTimerPrecision.microseconds", + 2000 + ); + + registerCleanupFunction(function () { + Services.prefs.clearUserPref("privacy.reduceTimerPrecision"); + Services.prefs.clearUserPref( + "privacy.resistFingerprinting.reduceTimerPrecision.microseconds" + ); + }); + + function background() { + browser.test.onMessage.addListener(async (msg, options) => { + if (msg == "removeCookies") { + await browser.browsingData.removeCookies(options); + } else { + await browser.browsingData.remove(options, { cookies: true }); + } + browser.test.sendMessage("cookiesRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + async function testRemovalMethod(method) { + // Clear cookies with a recent since value. + await setUpCookies(); + extension.sendMessage(method, { since }); + await extension.awaitMessage("cookiesRemoved"); + + ok( + Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + "Old cookie was not removed." + ); + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + "Recent cookie was removed." + ); + + // Clear cookies with an old since value. + await setUpCookies(); + addCookie(COOKIE); + extension.sendMessage(method, { since: since - 100000 }); + await extension.awaitMessage("cookiesRemoved"); + + ok( + !Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + "Old cookie was removed." + ); + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + "Recent cookie was removed." + ); + + // Clear cookies with no since value and valid originTypes. + await setUpCookies(); + extension.sendMessage(method, { + originTypes: { unprotectedWeb: true, protectedWeb: false }, + }); + await extension.awaitMessage("cookiesRemoved"); + + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + `Cookie ${COOKIE.name} was removed.` + ); + ok( + !Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + `Cookie ${oldCookie.name} was removed.` + ); + } + + await extension.startup(); + + await testRemovalMethod("removeCookies"); + await testRemovalMethod("remove"); + + await extension.unload(); +}); + +add_task(async function testCacheAndCookies() { + function background() { + browser.test.onMessage.addListener(async options => { + await browser.browsingData.remove(options, { + cache: true, + cookies: true, + }); + browser.test.sendMessage("cacheAndCookiesRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + await extension.startup(); + + // Clear cache and cookies with a recent since value. + await setUpCookies(); + await setUpCache(); + extension.sendMessage({ since }); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + ok( + Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + "Old cookie was not removed." + ); + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + "Recent cookie was removed." + ); + + // Cache does not support |since| and deletes everything! + ok(!hasCacheEntry("example.net"), "example.net cache was removed"); + ok(!hasCacheEntry("example.org"), "example.org cache was removed"); + ok(!hasCacheEntry("example.com"), "example.com cache was removed"); + + // Clear cache and cookies with an old since value. + await setUpCookies(); + await setUpCache(); + extension.sendMessage({ since: since - 100000 }); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + // Cache does not support |since| and deletes everything! + ok(!hasCacheEntry("example.net"), "example.net cache was removed"); + ok(!hasCacheEntry("example.org"), "example.org cache was removed"); + ok(!hasCacheEntry("example.com"), "example.com cache was removed"); + + ok( + !Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + "Old cookie was removed." + ); + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + "Recent cookie was removed." + ); + + // Clear cache and cookies with hostnames value. + await setUpCookies(); + await setUpCache(); + extension.sendMessage({ + hostnames: ["example.net", "example.org", "unknown.com"], + }); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + ok( + Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + `Cookie ${COOKIE.name} was not removed.` + ); + ok( + !Services.cookies.cookieExists( + COOKIE_NET.host, + COOKIE_NET.path, + COOKIE_NET.name, + {} + ), + `Cookie ${COOKIE_NET.name} was removed.` + ); + ok( + !Services.cookies.cookieExists( + COOKIE_ORG.host, + COOKIE_ORG.path, + COOKIE_ORG.name, + {} + ), + `Cookie ${COOKIE_ORG.name} was removed.` + ); + + ok(!hasCacheEntry("example.net"), "example.net cache was removed"); + ok(!hasCacheEntry("example.org"), "example.org cache was removed"); + ok(hasCacheEntry("example.com"), "example.com cache was not removed"); + + // Clear cache and cookies with (empty) hostnames value. + await setUpCookies(); + await setUpCache(); + extension.sendMessage({ hostnames: [] }); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + ok( + Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + `Cookie ${COOKIE.name} was not removed.` + ); + ok( + Services.cookies.cookieExists( + COOKIE_NET.host, + COOKIE_NET.path, + COOKIE_NET.name, + {} + ), + `Cookie ${COOKIE_NET.name} was not removed.` + ); + ok( + Services.cookies.cookieExists( + COOKIE_ORG.host, + COOKIE_ORG.path, + COOKIE_ORG.name, + {} + ), + `Cookie ${COOKIE_ORG.name} was not removed.` + ); + + ok(hasCacheEntry("example.net"), "example.net cache was not removed"); + ok(hasCacheEntry("example.org"), "example.org cache was not removed"); + ok(hasCacheEntry("example.com"), "example.com cache was not removed"); + + // Clear cache and cookies with both hostnames and since values. + await setUpCache(); + await setUpCookies(); + extension.sendMessage({ hostnames: ["example.com"], since }); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + ok( + Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + "Old cookie was not removed." + ); + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + "Recent cookie was removed." + ); + ok( + Services.cookies.cookieExists( + COOKIE_NET.host, + COOKIE_NET.path, + COOKIE_NET.name, + {} + ), + "Cookie with different hostname was not removed" + ); + ok( + Services.cookies.cookieExists( + COOKIE_ORG.host, + COOKIE_ORG.path, + COOKIE_ORG.name, + {} + ), + "Cookie with different hostname was not removed" + ); + + ok(hasCacheEntry("example.net"), "example.net cache was not removed"); + ok(hasCacheEntry("example.org"), "example.org cache was not removed"); + ok(!hasCacheEntry("example.com"), "example.com cache was removed"); + + // Clear cache and cookies with no since or hostnames value. + await setUpCache(); + await setUpCookies(); + extension.sendMessage({}); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + `Cookie ${COOKIE.name} was removed.` + ); + ok( + !Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + `Cookie ${oldCookie.name} was removed.` + ); + ok( + !Services.cookies.cookieExists( + COOKIE_NET.host, + COOKIE_NET.path, + COOKIE_NET.name, + {} + ), + `Cookie ${COOKIE_NET.name} was removed.` + ); + ok( + !Services.cookies.cookieExists( + COOKIE_ORG.host, + COOKIE_ORG.path, + COOKIE_ORG.name, + {} + ), + `Cookie ${COOKIE_ORG.name} was removed.` + ); + + ok(!hasCacheEntry("example.net"), "example.net cache was removed"); + ok(!hasCacheEntry("example.org"), "example.org cache was removed"); + ok(!hasCacheEntry("example.com"), "example.com cache was removed"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cookieStoreId.js b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cookieStoreId.js new file mode 100644 index 0000000000..d3d066efd2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cookieStoreId.js @@ -0,0 +1,192 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +// "Normal" cookie +const COOKIE_NORMAL = { + host: "example.com", + name: "test_cookie", + path: "/", + originAttributes: {}, +}; +// Private browsing cookie +const COOKIE_PRIVATE = { + host: "example.net", + name: "test_cookie", + path: "/", + originAttributes: { + privateBrowsingId: 1, + }, +}; +// "firefox-container-1" cookie +const COOKIE_CONTAINER = { + host: "example.org", + name: "test_cookie", + path: "/", + originAttributes: { + userContextId: 1, + }, +}; + +function cookieExists(cookie) { + return Services.cookies.cookieExists( + cookie.host, + cookie.path, + cookie.name, + cookie.originAttributes + ); +} + +function addCookie(cookie) { + const THE_FUTURE = Date.now() + 5 * 60; + + Services.cookies.add( + cookie.host, + cookie.path, + cookie.name, + "test", + false, + false, + false, + THE_FUTURE, + cookie.originAttributes, + Ci.nsICookie.SAMESITE_NONE, + Ci.nsICookie.SCHEME_HTTPS + ); + + ok(cookieExists(cookie), `Cookie ${cookie.name} was created.`); +} + +async function setUpCookies() { + Services.cookies.removeAll(); + + addCookie(COOKIE_NORMAL); + addCookie(COOKIE_PRIVATE); + addCookie(COOKIE_CONTAINER); +} + +add_task(async function testCookies() { + Services.prefs.setBoolPref("privacy.userContext.enabled", true); + + function background() { + browser.test.onMessage.addListener(async (msg, options) => { + if (msg == "removeCookies") { + await browser.browsingData.removeCookies(options); + } else { + await browser.browsingData.remove(options, { cookies: true }); + } + browser.test.sendMessage("cookiesRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + async function testRemovalMethod(method) { + // Clear only "normal"/default cookies. + await setUpCookies(); + + extension.sendMessage(method, { cookieStoreId: "firefox-default" }); + await extension.awaitMessage("cookiesRemoved"); + + ok(!cookieExists(COOKIE_NORMAL), "Normal cookie was removed"); + ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + + // Clear container cookie + await setUpCookies(); + + extension.sendMessage(method, { cookieStoreId: "firefox-container-1" }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed"); + ok(!cookieExists(COOKIE_CONTAINER), "Container cookie was removed"); + + // Clear private cookie + await setUpCookies(); + + extension.sendMessage(method, { cookieStoreId: "firefox-private" }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(!cookieExists(COOKIE_PRIVATE), "Private cookie was removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + + // Clear container cookie with correct hostname + await setUpCookies(); + + extension.sendMessage(method, { + cookieStoreId: "firefox-container-1", + hostnames: ["example.org"], + }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed"); + ok(!cookieExists(COOKIE_CONTAINER), "Container cookie was removed"); + + // Clear container cookie with incorrect hostname; nothing is removed + await setUpCookies(); + + extension.sendMessage(method, { + cookieStoreId: "firefox-container-1", + hostnames: ["example.com"], + }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + + // Clear private cookie with correct hostname + await setUpCookies(); + + extension.sendMessage(method, { + cookieStoreId: "firefox-private", + hostnames: ["example.net"], + }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(!cookieExists(COOKIE_PRIVATE), "Private cookie was removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + + // Clear private cookie with incorrect hostname; nothing is removed + await setUpCookies(); + + extension.sendMessage(method, { + cookieStoreId: "firefox-private", + hostnames: ["example.com"], + }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + + // Clear private cookie by hostname + await setUpCookies(); + + extension.sendMessage(method, { + hostnames: ["example.net"], + }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(!cookieExists(COOKIE_PRIVATE), "Private cookie was removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + } + + await extension.startup(); + + await testRemovalMethod("removeCookies"); + await testRemovalMethod("remove"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cache_api.js b/toolkit/components/extensions/test/xpcshell/test_ext_cache_api.js new file mode 100644 index 0000000000..14fa8a342a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cache_api.js @@ -0,0 +1,296 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +const server = createHttpServer({ + hosts: ["example.com", "anotherdomain.com"], +}); +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("test_ext_cache_api.js"); +}); + +add_setup(() => { + // NOTE: Services.io.offline shouldn't be set to offline, + // otherwise we would hit an unexpected behavior when + // the extension worker tries to fetch from an + // http url or cache an http url response, see Bug 1845317. + Assert.ok( + !Services.io.offline, + "Services.io.offline should not be set to true while running this test" + ); +}); + +add_task(async function test_cache_api_http_resource_allowed() { + async function background() { + try { + const BASE_URL = `http://example.com/dummy`; + + const cache = await window.caches.open("test-cache-api"); + browser.test.assertTrue( + await window.caches.has("test-cache-api"), + "CacheStorage.has should resolve to true" + ); + + // Test that adding and requesting cached http urls + // works as well. + await cache.add(BASE_URL); + browser.test.assertEq( + "test_ext_cache_api.js", + await cache.match(BASE_URL).then(res => res?.text()), + "Got the expected content from the cached http url" + ); + + // Test that the http urls that the cache API is allowed + // to fetch and cache are limited by the host permissions + // associated to the extensions (same as when the extension + // for fetch from those urls using fetch or XHR). + await browser.test.assertRejects( + cache.add(`http://anotherdomain.com/dummy`), + "NetworkError when attempting to fetch resource.", + "Got the expected rejection of requesting an http not allowed by host permissions" + ); + + // Test that deleting the cache storage works as expected. + browser.test.assertTrue( + await window.caches.delete("test-cache-api"), + "Cache deleted successfully" + ); + browser.test.assertTrue( + !(await window.caches.has("test-cache-api")), + "CacheStorage.has should resolve to false" + ); + } catch (err) { + browser.test.fail(`Unexpected error on using Cache API: ${err}`); + throw err; + } finally { + browser.test.sendMessage("test-cache-api-allowed"); + } + } + + // Verify that Cache API support for http urls is available + // regardless of extensions.backgroundServiceWorker.enabled pref. + const extension = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["http://example.com/*"] }, + background, + }); + + await extension.startup(); + + await extension.awaitMessage("test-cache-api-allowed"); + await extension.unload(); +}); + +// This test is similar to `test_cache_api_http_resource_allowed` but it does +// exercise the Cache API from a moz-extension shared worker. +// We expect the cache API calls to be successfull when it is being used to +// cache an HTTP url that is allowed for the extensions based on its host +// permission, but to fail if the extension doesn't have the required host +// permission to fetch data from that url. +add_task(async function test_cache_api_from_ext_shared_worker() { + if (!WebExtensionPolicy.useRemoteWebExtensions) { + // Ensure RemoteWorkerService has been initialized in the main + // process. + Services.obs.notifyObservers(null, "profile-after-change"); + } + + const background = async function () { + const BASE_URL_OK = `http://example.com/dummy`; + const BASE_URL_KO = `http://anotherdomain.com/dummy`; + const worker = new SharedWorker("worker.js"); + const { data: resultOK } = await new Promise(resolve => { + worker.port.onmessage = resolve; + worker.port.postMessage(["worker-cacheapi-test-allowed", BASE_URL_OK]); + }); + browser.test.assertDeepEq( + ["worker-cacheapi-test-allowed:result", { success: true }], + resultOK, + "Got success result from extension worker for allowed host url" + ); + const { data: resultKO } = await new Promise(resolve => { + worker.port.onmessage = resolve; + worker.port.postMessage(["worker-cacheapi-test-disallowed", BASE_URL_KO]); + }); + browser.test.assertDeepEq( + [ + "worker-cacheapi-test-disallowed:result", + { error: "NetworkError when attempting to fetch resource." }, + ], + resultKO, + "Got result from extension worker for disallowed host url" + ); + + browser.test.assertTrue( + await window.caches.has("test-cache-api"), + "CacheStorage.has should resolve to true" + ); + const cache = await window.caches.open("test-cache-api"); + browser.test.assertEq( + "test_ext_cache_api.js", + await cache.match(BASE_URL_OK).then(res => res?.text()), + "Got the expected content from the cached http url" + ); + browser.test.assertEq( + true, + await cache.match(BASE_URL_KO).then(res => res == undefined), + "Got no match for the http url that isn't allowed by host permissions" + ); + + browser.test.sendMessage("test-cacheapi-sharedworker:done"); + }; + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { permissions: ["http://example.com/*"] }, + files: { + "worker.js": function () { + self.onconnect = evt => { + const port = evt.ports[0]; + port.onmessage = async evt => { + let result = {}; + let message; + try { + const [msg, BASE_URL] = evt.data; + message = msg; + const cache = await self.caches.open("test-cache-api"); + await cache.add(BASE_URL); + result.success = true; + } catch (err) { + result.error = err.message; + throw err; + } finally { + port.postMessage([`${message}:result`, result]); + } + }; + }; + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("test-cacheapi-sharedworker:done"); + await extension.unload(); +}); + +add_task(async function test_cache_storage_evicted_on_addon_uninstalled() { + async function background() { + try { + const BASE_URL = `http://example.com/dummy`; + + const cache = await window.caches.open("test-cache-api"); + browser.test.assertTrue( + await window.caches.has("test-cache-api"), + "CacheStorage.has should resolve to true" + ); + + // Test that adding and requesting cached http urls + // works as well. + await cache.add(BASE_URL); + browser.test.assertEq( + "test_ext_cache_api.js", + await cache.match(BASE_URL).then(res => res?.text()), + "Got the expected content from the cached http url" + ); + } catch (err) { + browser.test.fail(`Unexpected error on using Cache API: ${err}`); + throw err; + } finally { + browser.test.sendMessage("cache-storage-created"); + } + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["http://example.com/*"] }, + background, + // Necessary to be sure the expected extension stored data cleanup callback + // will be called when the extension is uninstalled from an AddonManager + // perspective. + useAddonManager: "temporary", + }); + + await AddonTestUtils.promiseStartupManager(); + await extension.startup(); + await extension.awaitMessage("cache-storage-created"); + + const extURL = `moz-extension://${extension.extension.uuid}`; + const extPrincipal = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(extURL), + {} + ); + let extCacheStorage = new CacheStorage("content", extPrincipal); + + ok( + await extCacheStorage.has("test-cache-api"), + "Got the expected extension cache storage" + ); + + await extension.unload(); + + ok( + !(await extCacheStorage.has("test-cache-api")), + "The extension cache storage data should have been evicted on addon uninstall" + ); +}); + +add_task( + { + // Pref used to allow to use the Cache WebAPI related to a page loaded from http + // (otherwise Gecko will throw a SecurityError when trying to access the webpage + // cache storage from the content script, unless the webpage is loaded from https). + pref_set: [["dom.caches.testing.enabled", true]], + }, + async function test_cache_put_from_contentscript() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/*"], + js: ["content.js"], + }, + ], + }, + files: { + "content.js": async function () { + const cache = await caches.open("test-cachestorage"); + const request = "http://example.com"; + const response = await fetch(request); + await cache.put(request, response).catch(err => { + browser.test.sendMessage("cache-put-error", { + name: err.name, + message: err.message, + }); + }); + }, + }, + }); + + await extension.startup(); + + const page = await ExtensionTestUtils.loadContentPage("http://example.com"); + const actualError = await extension.awaitMessage("cache-put-error"); + equal( + actualError.name, + "SecurityError", + "Got a security error from cache.put call as expected" + ); + ok( + /Disallowed on WebExtension ContentScript Request/.test( + actualError.message + ), + `Got the expected error message: ${actualError.message}` + ); + + await page.close(); + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js new file mode 100644 index 0000000000..dfb5c4c415 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js @@ -0,0 +1,202 @@ +"use strict"; + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + true +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +/** + * This duplicates the test from netwerk/test/unit/test_captive_portal_service.js + * however using an extension to gather the captive portal information. + */ + +const PREF_CAPTIVE_ENABLED = "network.captive-portal-service.enabled"; +const PREF_CAPTIVE_TESTMODE = "network.captive-portal-service.testMode"; +const PREF_CAPTIVE_MINTIME = "network.captive-portal-service.minInterval"; +const PREF_CAPTIVE_ENDPOINT = "captivedetect.canonicalURL"; +const PREF_DNS_NATIVE_IS_LOCALHOST = "network.dns.native-is-localhost"; + +const SUCCESS_STRING = + ''; +let cpResponse = SUCCESS_STRING; + +const httpserver = createHttpServer(); +httpserver.registerPathHandler("/captive.txt", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/plain"); + response.write(cpResponse); +}); + +registerCleanupFunction(() => { + Services.prefs.clearUserPref(PREF_CAPTIVE_ENABLED); + Services.prefs.clearUserPref(PREF_CAPTIVE_TESTMODE); + Services.prefs.clearUserPref(PREF_CAPTIVE_ENDPOINT); + Services.prefs.clearUserPref(PREF_CAPTIVE_MINTIME); + Services.prefs.clearUserPref(PREF_DNS_NATIVE_IS_LOCALHOST); +}); + +add_task(async function setup() { + Services.prefs.setCharPref( + PREF_CAPTIVE_ENDPOINT, + `http://localhost:${httpserver.identity.primaryPort}/captive.txt` + ); + Services.prefs.setBoolPref(PREF_CAPTIVE_TESTMODE, true); + Services.prefs.setIntPref(PREF_CAPTIVE_MINTIME, 0); + Services.prefs.setBoolPref(PREF_DNS_NATIVE_IS_LOCALHOST, true); + + Services.prefs.setBoolPref("extensions.eventPages.enabled", true); + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_captivePortal_basic() { + let cps = Cc["@mozilla.org/network/captive-portal-service;1"].getService( + Ci.nsICaptivePortalService + ); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["captivePortal"], + background: { persistent: false }, + }, + isPrivileged: true, + async background() { + browser.captivePortal.onConnectivityAvailable.addListener(details => { + browser.test.log( + `onConnectivityAvailable received ${JSON.stringify(details)}` + ); + browser.test.sendMessage("connectivity", details); + }); + + browser.captivePortal.onStateChanged.addListener(details => { + browser.test.log(`onStateChanged received ${JSON.stringify(details)}`); + browser.test.sendMessage("state", details); + }); + + browser.captivePortal.canonicalURL.onChange.addListener(details => { + browser.test.sendMessage("url", details); + }); + + browser.test.onMessage.addListener(async msg => { + if (msg == "getstate") { + browser.test.sendMessage( + "getstate", + await browser.captivePortal.getState() + ); + } + }); + }, + }); + await extension.startup(); + + extension.sendMessage("getstate"); + let details = await extension.awaitMessage("getstate"); + equal(details, "unknown", "initial state"); + + // The captive portal service is started by nsIOService when the pref becomes true, so we + // toggle the pref. We cannot set to false before the extension loads above. + Services.prefs.setBoolPref(PREF_CAPTIVE_ENABLED, false); + Services.prefs.setBoolPref(PREF_CAPTIVE_ENABLED, true); + + details = await extension.awaitMessage("connectivity"); + equal(details.status, "clear", "initial connectivity"); + extension.sendMessage("getstate"); + details = await extension.awaitMessage("getstate"); + equal(details, "not_captive", "initial state"); + + info("REFRESH to other"); + cpResponse = "other"; + cps.recheckCaptivePortal(); + details = await extension.awaitMessage("state"); + equal(details.state, "locked_portal", "state in portal"); + + info("REFRESH to success"); + cpResponse = SUCCESS_STRING; + cps.recheckCaptivePortal(); + details = await extension.awaitMessage("connectivity"); + equal(details.status, "captive", "final connectivity"); + + details = await extension.awaitMessage("state"); + equal(details.state, "unlocked_portal", "state after unlocking portal"); + + assertPersistentListeners( + extension, + "captivePortal", + "onConnectivityAvailable", + { + primed: false, + } + ); + + assertPersistentListeners(extension, "captivePortal", "onStateChanged", { + primed: false, + }); + + assertPersistentListeners(extension, "captivePortal", "captiveURL.onChange", { + primed: false, + }); + + info("Test event page terminate/waken"); + + await extension.terminateBackground({ disableResetIdleForTest: true }); + ok( + !extension.extension.backgroundContext, + "Background Extension context should have been destroyed" + ); + + assertPersistentListeners(extension, "captivePortal", "onStateChanged", { + primed: true, + }); + assertPersistentListeners( + extension, + "captivePortal", + "onConnectivityAvailable", + { + primed: true, + } + ); + + assertPersistentListeners(extension, "captivePortal", "captiveURL.onChange", { + primed: true, + }); + + info("REFRESH 2nd pass to other"); + cpResponse = "other"; + cps.recheckCaptivePortal(); + details = await extension.awaitMessage("state"); + equal(details.state, "locked_portal", "state in portal"); + + info("Test event page terminate/waken with settings"); + + await extension.terminateBackground({ disableResetIdleForTest: true }); + ok( + !extension.extension.backgroundContext, + "Background Extension context should have been destroyed" + ); + + assertPersistentListeners(extension, "captivePortal", "captiveURL.onChange", { + primed: true, + }); + + Services.prefs.setStringPref( + "captivedetect.canonicalURL", + "http://example.com" + ); + let url = await extension.awaitMessage("url"); + equal( + url.value, + "http://example.com", + "The canonicalURL setting has the expected value." + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal_url.js b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal_url.js new file mode 100644 index 0000000000..7bd83b0572 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal_url.js @@ -0,0 +1,53 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_url_get_without_set() { + async function background() { + browser.captivePortal.canonicalURL.onChange.addListener(details => { + browser.test.sendMessage("url", details); + }); + let url = await browser.captivePortal.canonicalURL.get({}); + browser.test.sendMessage("url", url); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["captivePortal"], + }, + }); + + let defaultURL = Services.prefs.getStringPref("captivedetect.canonicalURL"); + + await extension.startup(); + let url = await extension.awaitMessage("url"); + equal( + url.value, + defaultURL, + "The canonicalURL setting has the expected value." + ); + equal( + url.levelOfControl, + "not_controllable", + "The canonicalURL setting has the expected levelOfControl." + ); + + Services.prefs.setStringPref( + "captivedetect.canonicalURL", + "http://example.com" + ); + url = await extension.awaitMessage("url"); + equal( + url.value, + "http://example.com", + "The canonicalURL setting has the expected value." + ); + equal( + url.levelOfControl, + "not_controllable", + "The canonicalURL setting has the expected levelOfControl." + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_clear_cached_resources.js b/toolkit/components/extensions/test/xpcshell/test_ext_clear_cached_resources.js new file mode 100644 index 0000000000..95bef23383 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_clear_cached_resources.js @@ -0,0 +1,418 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +Services.prefs.setBoolPref("extensions.blocklist.enabled", false); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); + +const LEAVE_UUID_PREF = "extensions.webextensions.keepUuidOnUninstall"; + +const BASE64_R_PIXEL = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P4z8DwHwAFAAH/F1FwBgAAAABJRU5ErkJggg=="; +const BASE64_G_PIXEL = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2Ng+M/wHwAEAQH/7yMK/gAAAABJRU5ErkJggg=="; +const BASE64_B_PIXEL = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2NgYPj/HwADAgH/eL9GtQAAAABJRU5ErkJggg=="; + +const toArrayBuffer = b64data => + Uint8Array.from(atob(b64data), c => c.charCodeAt(0)); +const IMAGE_RED = toArrayBuffer(BASE64_R_PIXEL).buffer; +const IMAGE_GREEN = toArrayBuffer(BASE64_G_PIXEL).buffer; +const IMAGE_BLUE = toArrayBuffer(BASE64_B_PIXEL).buffer; + +const RGB_RED = "rgb(255, 0, 0)"; +const RGB_GREEN = "rgb(0, 255, 0)"; +const RGB_BLUE = "rgb(0, 0, 255)"; + +const CSS_RED_BG = `body { background-color: ${RGB_RED}; }`; +const CSS_GREEN_BG = `body { background-color: ${RGB_GREEN}; }`; +const CSS_BLUE_BG = `body { background-color: ${RGB_BLUE}; }`; + +const ADDON_ID = "test-cached-resources@test"; + +const manifest = { + version: "1", + browser_specific_settings: { gecko: { id: ADDON_ID } }, +}; + +const files = { + "extpage.html": ` + + + + + + + + + `, + "other_extpage.html": ` + + + + + `, + "extpage.css": CSS_RED_BG, + "image.png": IMAGE_RED, +}; + +const getBackgroundColor = () => { + return this.content.getComputedStyle(this.content.document.body) + .backgroundColor; +}; + +const hasCachedImage = imgUrl => { + const { document } = this.content; + + const imageCache = Cc["@mozilla.org/image/tools;1"] + .getService(Ci.imgITools) + .getImgCacheForDocument(document); + + const imgCacheProps = imageCache.findEntryProperties( + Services.io.newURI(imgUrl), + document + ); + + // return true if the image was in the cache. + return !!imgCacheProps; +}; + +const getImageColor = () => { + const { document } = this.content; + const img = document.querySelector("img#test-image"); + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + const ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0); // Draw without scaling. + const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data; + if (a < 1) { + return `rgba(${r}, ${g}, ${b}, ${a})`; + } + return `rgb(${r}, ${g}, ${b})`; +}; + +async function assertBackgroundColor(page, color, message) { + equal( + await page.spawn([], getBackgroundColor), + color, + `Got the expected ${message}` + ); +} + +async function assertImageColor(page, color, message) { + equal(await page.spawn([], getImageColor), color, message); +} + +async function assertImageCached(page, imageUrl, message) { + ok(await page.spawn([imageUrl], hasCachedImage), message); +} + +// This test verifies that cached css are cleared across addon upgrades and downgrades +// for permanently installed addon (See Bug 1746841). +add_task(async function test_cached_resources_cleared_across_addon_updates() { + await AddonTestUtils.promiseStartupManager(); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest, + files, + }); + + await extension.startup(); + equal( + extension.version, + "1", + "Got the expected version for the initial extension" + ); + + const url = extension.extension.baseURI.resolve("extpage.html"); + let page = await ExtensionTestUtils.loadContentPage(url); + await assertBackgroundColor( + page, + RGB_RED, + "background color (initial extension version)" + ); + await assertImageColor(page, RGB_RED, "image (initial extension version)"); + + info("Verify extension page css and image after addon upgrade"); + + await extension.upgrade({ + useAddonManager: "permanent", + manifest: { + ...manifest, + version: "2", + }, + files: { + ...files, + "extpage.css": CSS_GREEN_BG, + "image.png": IMAGE_GREEN, + }, + }); + equal( + extension.version, + "2", + "Got the expected version for the upgraded extension" + ); + + await page.loadURL(url); + + await assertBackgroundColor( + page, + RGB_GREEN, + "background color (upgraded extension version)" + ); + await assertImageColor(page, RGB_GREEN, "image (upgraded extension version)"); + + info("Verify extension page css and image after addon downgrade"); + + await extension.upgrade({ + useAddonManager: "permanent", + manifest, + files, + }); + equal( + extension.version, + "1", + "Got the expected version for the downgraded extension" + ); + + await page.loadURL(url); + + await assertBackgroundColor( + page, + RGB_RED, + "background color (downgraded extension version)" + ); + await assertImageColor( + page, + RGB_RED, + "image color (downgraded extension version)" + ); + + await page.close(); + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); +}); + +// This test verifies that cached css are cleared if we are installing a new +// extension and we did not clear the cache for a previous one with the same uuid +// when it was uninstalled (See Bug 1746841). +add_task(async function test_cached_resources_cleared_on_addon_install() { + // Make sure the test addon installed without an AddonManager addon wrapper + // and the ones installed right after that using the AddonManager will share + // the same uuid (and so also the same moz-extension resource urls). + Services.prefs.setBoolPref(LEAVE_UUID_PREF, true); + registerCleanupFunction(() => Services.prefs.clearUserPref(LEAVE_UUID_PREF)); + + await AddonTestUtils.promiseStartupManager(); + + const nonAOMExtension = ExtensionTestUtils.loadExtension({ + manifest, + files: { + ...files, + // Override css with a different color from the one expected + // later in this test case. + "extpage.css": CSS_BLUE_BG, + "image.png": IMAGE_BLUE, + }, + }); + + await nonAOMExtension.startup(); + equal( + await AddonManager.getAddonByID(ADDON_ID), + null, + "No AOM addon wrapper found as expected" + ); + let url = nonAOMExtension.extension.baseURI.resolve("extpage.html"); + let page = await ExtensionTestUtils.loadContentPage(url); + await assertBackgroundColor( + page, + RGB_BLUE, + "background color (addon installed without uninstall observer)" + ); + await assertImageColor( + page, + RGB_BLUE, + "image (addon uninstalled without clearing cache)" + ); + + // NOTE: unloading a test extension that does not have an AddonManager addon wrapper + // does not trigger the uninstall observer, and this is what this test needs to make + // sure that if the cached resources were not cleared on uninstall, then we will still + // clear it when a newly installed addon is installed even if the two extensions + // are sharing the same addon uuid (and so also the same moz-extension resource urls). + await nonAOMExtension.unload(); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest, + files, + }); + + await extension.startup(); + await page.loadURL(url); + + await assertBackgroundColor( + page, + RGB_RED, + "background color (newly installed addon, same addon id)" + ); + await assertImageColor( + page, + RGB_RED, + "image (newly installed addon, same addon id)" + ); + + await page.close(); + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); +}); + +// This test verifies that reloading a temporarily installed addon after +// changing a css file cached in a previous run clears the previously +// cached css and uses the new one changed on disk (See Bug 1746841). +add_task( + async function test_cached_resources_cleared_on_temporary_addon_reload() { + await AddonTestUtils.promiseStartupManager(); + + const xpi = AddonTestUtils.createTempWebExtensionFile({ + manifest, + files, + }); + + // This temporary directory is going to be removed from the + // cleanup function, but also make it unique as we do for the + // other temporary files (e.g. like getTemporaryFile as defined + // in XPInstall.jsm). + const random = Math.round(Math.random() * 36 ** 3).toString(36); + const tmpDirName = `xpcshelltest_unpacked_addons_${random}`; + let tmpExtPath = FileUtils.getDir("TmpD", [tmpDirName]); + tmpExtPath.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + registerCleanupFunction(() => { + tmpExtPath.remove(true); + }); + + // Unpacking the xpi file into the temporary directory. + const extDir = await AddonTestUtils.manuallyInstall( + xpi, + tmpExtPath, + null, + /* unpacked */ true + ); + + let extension = ExtensionTestUtils.expectExtension(ADDON_ID); + await AddonManager.installTemporaryAddon(extDir); + await extension.awaitStartup(); + + equal( + extension.version, + "1", + "Got the expected version for the initial extension" + ); + + const url = extension.extension.baseURI.resolve("extpage.html"); + let page = await ExtensionTestUtils.loadContentPage(url); + await assertBackgroundColor( + page, + RGB_RED, + "background color (initial extension version)" + ); + await assertImageColor(page, RGB_RED, "image (initial extension version)"); + + info("Verify updated extension page css and image after addon reload"); + + const targetCSSFile = extDir.clone(); + targetCSSFile.append("extpage.css"); + ok( + targetCSSFile.exists(), + `Found the ${targetCSSFile.path} target file on disk` + ); + await IOUtils.writeUTF8(targetCSSFile.path, CSS_GREEN_BG); + + const targetPNGFile = extDir.clone(); + targetPNGFile.append("image.png"); + ok( + targetPNGFile.exists(), + `Found the ${targetPNGFile.path} target file on disk` + ); + await IOUtils.write(targetPNGFile.path, toArrayBuffer(BASE64_G_PIXEL)); + + const addon = await AddonManager.getAddonByID(ADDON_ID); + ok(addon, "Got an AddonWrapper for the test extension"); + await addon.reload(); + + await page.loadURL(url); + + await assertBackgroundColor( + page, + RGB_GREEN, + "background (updated files on disk)" + ); + await assertImageColor(page, RGB_GREEN, "image (updated files on disk)"); + + await page.close(); + await addon.uninstall(); + await AddonTestUtils.promiseShutdownManager(); + } +); + +// This test verifies that cached images are not cleared between +// permanently installed addon reloads. +add_task(async function test_cached_image_kept_on_permanent_addon_restarts() { + await AddonTestUtils.promiseStartupManager(); + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest, + files, + }); + + await extension.startup(); + + equal( + extension.version, + "1", + "Got the expected version for the initial extension" + ); + + const imageUrl = extension.extension.baseURI.resolve("image.png"); + const url = extension.extension.baseURI.resolve("extpage.html"); + + let page = await ExtensionTestUtils.loadContentPage(url); + await assertBackgroundColor( + page, + RGB_RED, + "background color (first startup)" + ); + await assertImageColor(page, RGB_RED, "image (first startup)"); + await assertImageCached(page, imageUrl, "image cached (first startup)"); + + info("Reload the AddonManager to simulate browser restart"); + extension.setRestarting(); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + await page.loadURL(extension.extension.baseURI.resolve("other_extpage.html")); + await assertImageCached( + page, + imageUrl, + "image still cached after AddonManager restart" + ); + + await page.close(); + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js new file mode 100644 index 0000000000..c92ed11022 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js @@ -0,0 +1,808 @@ +"use strict"; + +const { createAppInfo } = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49"); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +function check_applied_styles() { + const urlElStyle = getComputedStyle( + document.querySelector("#registered-extension-url-style") + ); + const blobElStyle = getComputedStyle( + document.querySelector("#registered-extension-text-style") + ); + + browser.test.sendMessage("registered-styles-results", { + registeredExtensionUrlStyleBG: urlElStyle["background-color"], + registeredExtensionBlobStyleBG: blobElStyle["background-color"], + }); +} + +add_task(async function test_contentscripts_register_css() { + async function background() { + let cssCode = ` + #registered-extension-text-style { + background-color: blue; + } + `; + + const matches = ["http://localhost/*/file_sample_registered_styles.html"]; + + browser.test.assertThrows( + () => { + browser.contentScripts.register({ + matches, + unknownParam: "unexpected property", + }); + }, + /Unexpected property "unknownParam"/, + "contentScripts.register throws on unexpected properties" + ); + + let fileScript = await browser.contentScripts.register({ + css: [{ file: "registered_ext_style.css" }], + matches, + runAt: "document_start", + }); + + let textScript = await browser.contentScripts.register({ + css: [{ code: cssCode }], + matches, + runAt: "document_start", + }); + + browser.test.onMessage.addListener(async msg => { + switch (msg) { + case "unregister-text": + await textScript.unregister().catch(err => { + browser.test.fail( + `Unexpected exception while unregistering text style: ${err}` + ); + }); + + await browser.test.assertRejects( + textScript.unregister(), + /Content script already unregistered/, + "Got the expected rejection on calling script.unregister() multiple times" + ); + + browser.test.sendMessage("unregister-text:done"); + break; + case "unregister-file": + await fileScript.unregister().catch(err => { + browser.test.fail( + `Unexpected exception while unregistering url style: ${err}` + ); + }); + + await browser.test.assertRejects( + fileScript.unregister(), + /Content script already unregistered/, + "Got the expected rejection on calling script.unregister() multiple times" + ); + + browser.test.sendMessage("unregister-file:done"); + break; + default: + browser.test.fail(`Unexpected test message received: ${msg}`); + } + }); + + browser.test.sendMessage("background_ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "http://localhost/*/file_sample_registered_styles.html", + "", + ], + content_scripts: [ + { + matches: ["http://localhost/*/file_sample_registered_styles.html"], + run_at: "document_idle", + js: ["check_applied_styles.js"], + }, + ], + }, + background, + + files: { + "check_applied_styles.js": check_applied_styles, + "registered_ext_style.css": ` + #registered-extension-url-style { + background-color: red; + } + `, + }, + }); + + await extension.startup(); + + await extension.awaitMessage("background_ready"); + + // Ensure that a content page running in a content process and which has been + // started after the content scripts has been registered, it still receives + // and registers the expected content scripts. + let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); + + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + const registeredStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + registeredStylesResults.registeredExtensionUrlStyleBG, + "rgb(255, 0, 0)", + "The expected style has been applied from the registered extension url style" + ); + equal( + registeredStylesResults.registeredExtensionBlobStyleBG, + "rgb(0, 0, 255)", + "The expected style has been applied from the registered extension blob style" + ); + + extension.sendMessage("unregister-file"); + await extension.awaitMessage("unregister-file:done"); + + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + const unregisteredURLStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + unregisteredURLStylesResults.registeredExtensionUrlStyleBG, + "rgba(0, 0, 0, 0)", + "The expected style has been applied once extension url style has been unregistered" + ); + equal( + unregisteredURLStylesResults.registeredExtensionBlobStyleBG, + "rgb(0, 0, 255)", + "The expected style has been applied from the registered extension blob style" + ); + + extension.sendMessage("unregister-text"); + await extension.awaitMessage("unregister-text:done"); + + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + const unregisteredBlobStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + unregisteredBlobStylesResults.registeredExtensionUrlStyleBG, + "rgba(0, 0, 0, 0)", + "The expected style has been applied once extension url style has been unregistered" + ); + equal( + unregisteredBlobStylesResults.registeredExtensionBlobStyleBG, + "rgba(0, 0, 0, 0)", + "The expected style has been applied once extension blob style has been unregistered" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_contentscripts_unregister_on_context_unload() { + async function background() { + const frame = document.createElement("iframe"); + frame.setAttribute("src", "/background-frame.html"); + + document.body.appendChild(frame); + + browser.test.onMessage.addListener(msg => { + switch (msg) { + case "unload-frame": + frame.remove(); + browser.test.sendMessage("unload-frame:done"); + break; + default: + browser.test.fail(`Unexpected test message received: ${msg}`); + } + }); + + browser.test.sendMessage("background_ready"); + } + + async function background_frame() { + await browser.contentScripts.register({ + css: [{ file: "registered_ext_style.css" }], + matches: ["http://localhost/*/file_sample_registered_styles.html"], + runAt: "document_start", + }); + + browser.test.sendMessage("background_frame_ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://localhost/*/file_sample_registered_styles.html"], + content_scripts: [ + { + matches: ["http://localhost/*/file_sample_registered_styles.html"], + run_at: "document_idle", + js: ["check_applied_styles.js"], + }, + ], + }, + background, + + files: { + "background-frame.html": ` + + + + + + + + `, + "background-frame.js": background_frame, + "check_applied_styles.js": check_applied_styles, + "registered_ext_style.css": ` + #registered-extension-url-style { + background-color: red; + } + `, + }, + }); + + await extension.startup(); + + await extension.awaitMessage("background_ready"); + + // Wait the background frame to have been loaded and its script + // executed. + await extension.awaitMessage("background_frame_ready"); + + // Ensure that a content page running in a content process and which has been + // started after the content scripts has been registered, it still receives + // and registers the expected content scripts. + let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); + + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + const registeredStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + registeredStylesResults.registeredExtensionUrlStyleBG, + "rgb(255, 0, 0)", + "The expected style has been applied from the registered extension url style" + ); + + extension.sendMessage("unload-frame"); + await extension.awaitMessage("unload-frame:done"); + + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + const unregisteredURLStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + unregisteredURLStylesResults.registeredExtensionUrlStyleBG, + "rgba(0, 0, 0, 0)", + "The expected style has been applied once extension url style has been unregistered" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_contentscripts_register_js() { + async function background() { + browser.runtime.onMessage.addListener( + ([msg, expectedStates, readyState], sender) => { + if (msg == "chrome-namespace-ok") { + browser.test.sendMessage(msg); + return; + } + + browser.test.assertEq("script-run", msg, "message type is correct"); + browser.test.assertTrue( + expectedStates.includes(readyState), + `readyState "${readyState}" is one of [${expectedStates}]` + ); + browser.test.sendMessage("script-run-" + expectedStates[0]); + } + ); + + // Raise an exception when the content script cannot be registered + // because the extension has no permission to access the specified origin. + + await browser.test.assertRejects( + browser.contentScripts.register({ + matches: ["http://*/*"], + js: [ + { + code: 'browser.test.fail("content script with wrong matches should not run")', + }, + ], + }), + /Permission denied to register a content script for/, + "The reject contains the expected error message" + ); + + // Register a content script from a JS code string. + + function textScriptCodeStart() { + browser.runtime.sendMessage([ + "script-run", + ["loading"], + document.readyState, + ]); + } + function textScriptCodeEnd() { + browser.runtime.sendMessage([ + "script-run", + ["interactive", "complete"], + document.readyState, + ]); + } + function textScriptCodeIdle() { + browser.runtime.sendMessage([ + "script-run", + ["complete"], + document.readyState, + ]); + } + + // Register content scripts from both extension URLs and plain JS code strings. + + const content_scripts = [ + // Plain JS code strings. + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ code: `(${textScriptCodeStart})()` }], + runAt: "document_start", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ code: `(${textScriptCodeEnd})()` }], + runAt: "document_end", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ code: `(${textScriptCodeIdle})()` }], + runAt: "document_idle", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ code: `(${textScriptCodeIdle})()` }], + runAt: "document_idle", + cookieStoreId: "firefox-container-1", + }, + // Extension URLs. + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ file: "content_script_start.js" }], + runAt: "document_start", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ file: "content_script_end.js" }], + runAt: "document_end", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ file: "content_script_idle.js" }], + runAt: "document_idle", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ file: "content_script.js" }], + // "runAt" is not specified here to ensure that it defaults to document_idle when missing. + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ file: "content_script_idle.js" }], + runAt: "document_idle", + cookieStoreId: "firefox-container-1", + }, + ]; + + const expectedAPIs = ["unregister"]; + + for (const scriptOptions of content_scripts) { + const script = await browser.contentScripts.register(scriptOptions); + const actualAPIs = Object.keys(script); + + browser.test.assertEq( + JSON.stringify(expectedAPIs), + JSON.stringify(actualAPIs), + `Got a script API object for ${scriptOptions.js[0]}` + ); + } + + browser.test.sendMessage("background-ready"); + } + + function contentScriptStart() { + browser.runtime.sendMessage([ + "script-run", + ["loading"], + document.readyState, + ]); + } + function contentScriptEnd() { + browser.runtime.sendMessage([ + "script-run", + ["interactive", "complete"], + document.readyState, + ]); + } + function contentScriptIdle() { + browser.runtime.sendMessage([ + "script-run", + ["complete"], + document.readyState, + ]); + } + + function contentScript() { + let manifest = browser.runtime.getManifest(); + void manifest.permissions; + browser.runtime.sendMessage(["chrome-namespace-ok"]); + } + + let extensionData = { + manifest: { + permissions: ["http://localhost/*/file_sample.html"], + }, + background, + + files: { + "content_script_start.js": contentScriptStart, + "content_script_end.js": contentScriptEnd, + "content_script_idle.js": contentScriptIdle, + "content_script.js": contentScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + let loadingCount = 0; + let interactiveCount = 0; + let completeCount = 0; + extension.onMessage("script-run-loading", () => { + loadingCount++; + }); + extension.onMessage("script-run-interactive", () => { + interactiveCount++; + }); + + let completePromise = new Promise(resolve => { + extension.onMessage("script-run-complete", () => { + completeCount++; + if (completeCount == 2) { + resolve(); + } + }); + }); + + let chromeNamespacePromise = extension.awaitMessage("chrome-namespace-ok"); + + // Ensure that a content page running in a content process and which has been + // already loaded when the content scripts has been registered, it has received + // and registered the expected content scripts. + let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + await contentPage.loadURL(`${BASE_URL}/file_sample.html`); + + await Promise.all([completePromise, chromeNamespacePromise]); + + await contentPage.close(); + + // Expect two content scripts to run (one registered using an extension URL + // and one registered from plain JS code). + equal(loadingCount, 2, "document_start script ran exactly twice"); + equal(interactiveCount, 2, "document_end script ran exactly twice"); + equal(completeCount, 2, "document_idle script ran exactly twice"); + + await extension.unload(); +}); + +// Test that the contentScripts.register options are correctly translated +// into the expected WebExtensionContentScript properties. +add_task(async function test_contentscripts_register_all_options() { + async function background() { + await browser.contentScripts.register({ + js: [{ file: "content_script.js" }], + css: [{ file: "content_style.css" }], + matches: ["http://localhost/*"], + excludeMatches: ["http://localhost/exclude/*"], + excludeGlobs: ["*_exclude.html"], + includeGlobs: ["*_include.html"], + allFrames: true, + matchAboutBlank: true, + runAt: "document_start", + }); + + browser.test.sendMessage("background-ready", window.location.origin); + } + + const extensionData = { + manifest: { + permissions: ["http://localhost/*"], + }, + background, + + files: { + "content_script.js": "", + "content_style.css": "", + }, + }; + + const extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + const baseExtURL = await extension.awaitMessage("background-ready"); + + const policy = WebExtensionPolicy.getByID(extension.id); + + ok(policy, "Got the WebExtensionPolicy for the test extension"); + equal( + policy.contentScripts.length, + 1, + "Got the expected number of registered content scripts" + ); + + const script = policy.contentScripts[0]; + let { + allFrames, + cssPaths, + jsPaths, + matchAboutBlank, + runAt, + originAttributesPatterns, + } = script; + + deepEqual( + { + allFrames, + cssPaths, + jsPaths, + matchAboutBlank, + runAt, + originAttributesPatterns, + }, + { + allFrames: true, + cssPaths: [`${baseExtURL}/content_style.css`], + jsPaths: [`${baseExtURL}/content_script.js`], + matchAboutBlank: true, + runAt: "document_start", + originAttributesPatterns: null, + }, + "Got the expected content script properties" + ); + + ok( + script.matchesURI(Services.io.newURI("http://localhost/ok_include.html")), + "matched and include globs should match" + ); + ok( + !script.matchesURI( + Services.io.newURI("http://localhost/exclude/ok_include.html") + ), + "exclude matches should not match" + ); + ok( + !script.matchesURI(Services.io.newURI("http://localhost/ok_exclude.html")), + "exclude globs should not match" + ); + + await extension.unload(); +}); + +add_task(async function test_contentscripts_register_cookieStoreId() { + async function background() { + let cookieStoreIdCSSArray = [ + { id: null, color: "rgb(123, 45, 67)" }, + { id: "firefox-private", color: "rgb(255,255,0)" }, + { id: "firefox-default", color: "red" }, + { id: "firefox-container-1", color: "green" }, + { id: "firefox-container-2", color: "blue" }, + { + id: ["firefox-container-3", "firefox-container-4"], + color: "rgb(100,100,0)", + }, + ]; + const matches = ["http://localhost/*/file_sample_registered_styles.html"]; + + for (let { id, color } of cookieStoreIdCSSArray) { + await browser.contentScripts.register({ + css: [ + { + code: `#registered-extension-text-style { + background-color: ${color}}`, + }, + ], + matches, + runAt: "document_start", + cookieStoreId: id, + }); + } + await browser.test.assertRejects( + browser.contentScripts.register({ + css: [{ code: `body {}` }], + matches, + cookieStoreId: "not_a_valid_cookieStoreId", + }), + /Invalid cookieStoreId/, + "contentScripts.register with an invalid cookieStoreId" + ); + + if (!navigator.userAgent.includes("Android")) { + await browser.test.assertRejects( + browser.contentScripts.register({ + css: [{ code: `body {}` }], + matches, + cookieStoreId: "firefox-container-999", + }), + /Invalid cookieStoreId/, + "contentScripts.register with an invalid cookieStoreId" + ); + } else { + // On Android, any firefox-container-... is treated as valid, so it doesn't + // result in an error. + // TODO bug 1743616: Fix implementation and remove this branch. + await browser.contentScripts.register({ + css: [{ code: `body {}` }], + matches, + cookieStoreId: "firefox-container-999", + }); + } + + await browser.test.assertRejects( + browser.contentScripts.register({ + css: [{ code: `body {}` }], + matches, + cookieStoreId: "", + }), + /Invalid cookieStoreId/, + "contentScripts.register with an invalid cookieStoreId" + ); + + browser.test.sendMessage("background_ready"); + } + + const extensionData = { + manifest: { + permissions: [ + "http://localhost/*/file_sample_registered_styles.html", + "", + ], + content_scripts: [ + { + matches: ["http://localhost/*/file_sample_registered_styles.html"], + run_at: "document_idle", + js: ["check_applied_styles.js"], + }, + ], + }, + background, + files: { + "check_applied_styles.js": check_applied_styles, + }, + }; + + const extension = ExtensionTestUtils.loadExtension({ + ...extensionData, + incognitoOverride: "spanning", + }); + + await extension.startup(); + await extension.awaitMessage("background_ready"); + // Index 0 is the one from manifest.json. + let contentScriptMatchTests = [ + { + contentPageOptions: { userContextId: 5 }, + expectedStyles: "rgb(123, 45, 67)", + originAttributesPatternExpected: null, + contentScriptIndex: 1, + }, + { + contentPageOptions: { privateBrowsing: true }, + expectedStyles: "rgb(255, 255, 0)", + originAttributesPatternExpected: [ + { + privateBrowsingId: 1, + userContextId: 0, + }, + ], + contentScriptIndex: 2, + }, + { + contentPageOptions: { userContextId: 0 }, + expectedStyles: "rgb(255, 0, 0)", + originAttributesPatternExpected: [ + { + privateBrowsingId: 0, + userContextId: 0, + }, + ], + contentScriptIndex: 3, + }, + { + contentPageOptions: { userContextId: 1 }, + expectedStyles: "rgb(0, 128, 0)", + originAttributesPatternExpected: [{ userContextId: 1 }], + contentScriptIndex: 4, + }, + { + contentPageOptions: { userContextId: 2 }, + expectedStyles: "rgb(0, 0, 255)", + originAttributesPatternExpected: [{ userContextId: 2 }], + contentScriptIndex: 5, + }, + { + contentPageOptions: { userContextId: 3 }, + expectedStyles: "rgb(100, 100, 0)", + originAttributesPatternExpected: [ + { userContextId: 3 }, + { userContextId: 4 }, + ], + contentScriptIndex: 6, + }, + { + contentPageOptions: { userContextId: 4 }, + expectedStyles: "rgb(100, 100, 0)", + originAttributesPatternExpected: [ + { userContextId: 3 }, + { userContextId: 4 }, + ], + contentScriptIndex: 6, + }, + ]; + + const policy = WebExtensionPolicy.getByID(extension.id); + + for (const testCase of contentScriptMatchTests) { + const { + contentPageOptions, + expectedStyles, + originAttributesPatternExpected, + contentScriptIndex, + } = testCase; + const script = policy.contentScripts[contentScriptIndex]; + + deepEqual(script.originAttributesPatterns, originAttributesPatternExpected); + let contentPage = await ExtensionTestUtils.loadContentPage( + `about:blank`, + contentPageOptions + ); + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + let registeredStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + registeredStylesResults.registeredExtensionBlobStyleBG, + expectedStyles, + `Expected styles applied on content page loaded with options + ${JSON.stringify(contentPageOptions)}` + ); + await contentPage.close(); + } + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js b/toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js new file mode 100644 index 0000000000..335a278329 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js @@ -0,0 +1,362 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write(""); +}); + +server.registerPathHandler("/worker.js", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "application/javascript", false); + response.write("let x = true;"); +}); + +const baseCSP = []; +// Keep in sync with extensions.webextensions.base-content-security-policy +baseCSP[2] = { + "script-src": [ + "'unsafe-eval'", + "'wasm-unsafe-eval'", + "'unsafe-inline'", + "blob:", + "filesystem:", + "http://localhost:*", + "http://127.0.0.1:*", + "https://*", + "moz-extension:", + "'self'", + ], +}; +// Keep in sync with extensions.webextensions.base-content-security-policy.v3 +baseCSP[3] = { + "script-src": ["'self'", "'wasm-unsafe-eval'"], +}; + +/** + * @typedef TestPolicyExpects + * @type {object} + * @param {boolean} workerEvalAllowed + * @param {boolean} workerImportScriptsAllowed + * @param {boolean} workerWasmAllowed + */ + +/** + * Tests that content security policies for an add-on are actually applied to * + * documents that belong to it. This tests both the base policies and add-on + * specific policies, and ensures that the parsed policies applied to the + * document's principal match what was specified in the policy string. + * + * @param {object} options + * @param {number} [options.manifest_version] + * @param {object} [options.customCSP] + * @param {TestPolicyExpects} options.expects + */ +async function testPolicy({ + manifest_version = 2, + customCSP = null, + expects = {}, +}) { + info( + `Enter tests for extension CSP with ${JSON.stringify({ + manifest_version, + customCSP, + })}` + ); + + let baseURL; + + let addonCSP = { + "script-src": ["'self'"], + }; + + if (manifest_version < 3) { + addonCSP["script-src"].push("'wasm-unsafe-eval'"); + } + + let content_security_policy = null; + + if (customCSP) { + for (let key of Object.keys(customCSP)) { + addonCSP[key] = customCSP[key].split(/\s+/); + } + + content_security_policy = Object.keys(customCSP) + .map(key => `${key} ${customCSP[key]}`) + .join("; "); + } + + function checkSource(name, policy, expected) { + // fallback to script-src when comparing worker-src if policy does not include worker-src + let policySrc = + name != "worker-src" || policy[name] + ? policy[name] + : policy["script-src"]; + equal( + JSON.stringify(policySrc.sort()), + JSON.stringify(expected[name].sort()), + `Expected value for ${name}` + ); + } + + function checkCSP(csp, location) { + let policies = csp["csp-policies"]; + + info(`Base policy for ${location}`); + let base = baseCSP[manifest_version]; + + equal(policies[0]["report-only"], false, "Policy is not report-only"); + for (let key in base) { + checkSource(key, policies[0], base); + } + + info(`Add-on policy for ${location}`); + + equal(policies[1]["report-only"], false, "Policy is not report-only"); + for (let key in addonCSP) { + checkSource(key, policies[1], addonCSP); + } + } + + function background() { + browser.test.sendMessage( + "base-url", + browser.runtime.getURL("").replace(/\/$/, "") + ); + + browser.test.sendMessage("background-csp", window.getCsp()); + } + + function tabScript() { + browser.test.sendMessage("tab-csp", window.getCsp()); + + const worker = new Worker("worker.js"); + worker.onmessage = event => { + browser.test.sendMessage("worker-csp", event.data); + }; + + worker.postMessage({}); + } + + function testWorker(port) { + this.onmessage = () => { + let importScriptsAllowed; + let evalAllowed; + let wasmAllowed; + + try { + eval("let y = true;"); // eslint-disable-line no-eval + evalAllowed = true; + } catch (e) { + evalAllowed = false; + } + + try { + new WebAssembly.Module( + new Uint8Array([0, 0x61, 0x73, 0x6d, 0x1, 0, 0, 0]) + ); + wasmAllowed = true; + } catch (e) { + wasmAllowed = false; + } + + try { + // eslint-disable-next-line no-undef + importScripts(`http://127.0.0.1:${port}/worker.js`); + importScriptsAllowed = true; + } catch (e) { + importScriptsAllowed = false; + } + + postMessage({ evalAllowed, importScriptsAllowed, wasmAllowed }); + }; + } + + let web_accessible_resources = ["content.html", "tab.html"]; + if (manifest_version == 3) { + let extension_pages = content_security_policy; + content_security_policy = { + extension_pages, + }; + let resources = web_accessible_resources; + web_accessible_resources = [ + { resources, matches: ["http://example.com/*"] }, + ]; + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + + files: { + "tab.html": ` + + + `, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + let awaitConsole = new Promise(resolve => { + Services.console.registerListener(function listener(message) { + if (/WebExt Privilege Escalation/.test(message.message)) { + Services.console.unregisterListener(listener); + resolve(message); + } + }); + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + let message = await awaitConsole; + ok( + message.message.includes( + "WebExt Privilege Escalation: typeof(browser) = undefined" + ), + "Document does not have `browser` APIs." + ); + + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_async_loading.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_async_loading.js new file mode 100644 index 0000000000..cb9a07142d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_async_loading.js @@ -0,0 +1,79 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write(""); +}); + +add_task(async function test_async_loading() { + const adder = `(function add(a = 1) { this.count += a; })();\n`; + + const extension = { + manifest: { + content_scripts: [ + { + run_at: "document_start", + matches: ["http://example.com/dummy"], + js: ["first.js", "second.js"], + }, + { + run_at: "document_end", + matches: ["http://example.com/dummy"], + js: ["third.js"], + }, + ], + }, + files: { + "first.js": ` + this.count = 0; + ${adder.repeat(50000)}; // 2Mb + browser.test.assertEq(this.count, 50000, "A 50k line script"); + + this.order = (this.order || 0) + 1; + browser.test.sendMessage("first", this.order); + `, + "second.js": ` + this.order = (this.order || 0) + 1; + browser.test.sendMessage("second", this.order); + `, + "third.js": ` + this.order = (this.order || 0) + 1; + browser.test.sendMessage("third", this.order); + `, + }, + }; + + async function checkOrder(ext) { + const [first, second, third] = await Promise.all([ + ext.awaitMessage("first"), + ext.awaitMessage("second"), + ext.awaitMessage("third"), + ]); + + equal(first, 1, "first.js finished execution first."); + equal(second, 2, "second.js finished execution second."); + equal(third, 3, "third.js finished execution third."); + } + + info("Test pages observed while extension is running"); + const observed = ExtensionTestUtils.loadExtension(extension); + await observed.startup(); + + const contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await checkOrder(observed); + await observed.unload(); + + info("Test pages already existing on extension startup"); + const existing = ExtensionTestUtils.loadExtension(extension); + + await existing.startup(); + await checkOrder(existing); + await existing.unload(); + + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_canvas_tainting.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_canvas_tainting.js new file mode 100644 index 0000000000..4ac22dc700 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_canvas_tainting.js @@ -0,0 +1,128 @@ +"use strict"; + +const server = createHttpServer({ + hosts: ["green.example.com", "red.example.com"], +}); + +server.registerDirectory("/data/", do_get_file("data")); + +server.registerPathHandler("/pixel.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write(` + + `); +}); + +add_task(async function test_contentscript_canvas_tainting() { + async function contentScript() { + let canvas = document.createElement("canvas"); + let ctx = canvas.getContext("2d"); + document.body.appendChild(canvas); + + function draw(url) { + return new Promise(resolve => { + let img = document.createElement("img"); + img.onload = () => { + ctx.drawImage(img, 0, 0, 1, 1); + resolve(); + }; + img.src = url; + }); + } + + function readByExt() { + let { data } = ctx.getImageData(0, 0, 1, 1); + return data.slice(0, 3).join(); + } + + let readByWeb = window.wrappedJSObject.readByWeb; + + // Test reading after drawing an image from the same origin as the web page. + await draw("http://green.example.com/data/pixel_green.gif"); + browser.test.assertEq( + readByWeb(), + "0,255,0", + "Content can read same-origin image" + ); + browser.test.assertEq( + readByExt(), + "0,255,0", + "Extension can read same-origin image" + ); + + // Test reading after drawing a blue pixel data URI from extension content script. + await draw( + "data:image/gif;base64,R0lGODlhAQABAIABAAAA/wAAACwAAAAAAQABAAACAkQBADs=" + ); + browser.test.assertThrows( + readByWeb, + /operation is insecure/, + "Content can't read extension's image" + ); + browser.test.assertEq( + readByExt(), + "0,0,255", + "Extension can read its own image" + ); + + // Test after tainting the canvas with an image from a third party domain. + await draw("http://red.example.com/data/pixel_red.gif"); + browser.test.assertThrows( + readByWeb, + /operation is insecure/, + "Content can't read third party image" + ); + browser.test.assertThrows( + readByExt, + /operation is insecure/, + "Extension can't read fully tainted" + ); + + // Test canvas is still fully tainted after drawing extension's data: image again. + await draw( + "data:image/gif;base64,R0lGODlhAQABAIABAAAA/wAAACwAAAAAAQABAAACAkQBADs=" + ); + browser.test.assertThrows( + readByWeb, + /operation is insecure/, + "Canvas still fully tainted for content" + ); + browser.test.assertThrows( + readByExt, + /operation is insecure/, + "Canvas still fully tainted for extension" + ); + + browser.test.sendMessage("done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://green.example.com/pixel.html"], + js: ["cs.js"], + }, + ], + }, + files: { + "cs.js": contentScript, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://green.example.com/pixel.html" + ); + await extension.awaitMessage("done"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context.js new file mode 100644 index 0000000000..fc27b84200 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context.js @@ -0,0 +1,359 @@ +"use strict"; + +/* eslint-disable mozilla/balanced-listeners */ + +const server = createHttpServer({ hosts: ["example.com", "example.org"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write(""); +}); + +function loadExtension() { + function contentScript() { + browser.test.sendMessage("content-script-ready"); + + window.addEventListener( + "pagehide", + () => { + browser.test.sendMessage("content-script-hide"); + }, + true + ); + window.addEventListener("pageshow", () => { + browser.test.sendMessage("content-script-show"); + }); + } + + return ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy*"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); +} + +add_task(async function test_contentscript_context() { + let extension = loadExtension(); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitMessage("content-script-ready"); + await extension.awaitMessage("content-script-show"); + + // Get the content script context and check that it points to the correct window. + await contentPage.legacySpawn(extension.id, async extensionId => { + const { ExtensionContent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionContent.sys.mjs" + ); + this.context = ExtensionContent.getContextByExtensionId( + extensionId, + this.content + ); + + Assert.ok(this.context, "Got content script context"); + + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property is correct" + ); + + // Navigate so that the content page is hidden in the bfcache. + + this.content.location = "http://example.org/dummy"; + }); + + await extension.awaitMessage("content-script-hide"); + + await contentPage.legacySpawn(null, async () => { + Assert.equal( + this.context.contentWindow, + null, + "Context's contentWindow property is null" + ); + + // Navigate back so the content page is resurrected from the bfcache. + this.content.history.back(); + }); + + await extension.awaitMessage("content-script-show"); + + await contentPage.legacySpawn(null, async () => { + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property is correct" + ); + }); + + await contentPage.close(); + await extension.awaitMessage("content-script-hide"); + await extension.unload(); +}); + +add_task(async function test_contentscript_context_incognito_not_allowed() { + async function background() { + await browser.contentScripts.register({ + js: [{ file: "registered_script.js" }], + matches: ["http://example.com/dummy"], + runAt: "document_start", + }); + + browser.test.sendMessage("background-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + permissions: ["http://example.com/*"], + }, + background, + files: { + "content_script.js": () => { + browser.test.notifyFail("content_script_loaded"); + }, + "registered_script.js": () => { + browser.test.notifyFail("registered_script_loaded"); + }, + }, + }); + + // Bug 1715801: Re-enable pbm portion on GeckoView + if (AppConstants.platform == "android") { + Services.prefs.setBoolPref("dom.security.https_first_pbm", false); + } + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { privateBrowsing: true } + ); + + await contentPage.legacySpawn(extension.id, async extensionId => { + const { ExtensionContent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionContent.sys.mjs" + ); + let context = ExtensionContent.getContextByExtensionId( + extensionId, + this.content + ); + Assert.equal( + context, + null, + "Extension unable to use content_script in private browsing window" + ); + }); + + await contentPage.close(); + await extension.unload(); + + // Bug 1715801: Re-enable pbm portion on GeckoView + if (AppConstants.platform == "android") { + Services.prefs.clearUserPref("dom.security.https_first_pbm"); + } +}); + +add_task(async function test_contentscript_context_unload_while_in_bfcache() { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy?first" + ); + let extension = loadExtension(); + await extension.startup(); + await extension.awaitMessage("content-script-ready"); + + // Get the content script context and check that it points to the correct window. + await contentPage.legacySpawn(extension.id, async extensionId => { + const { ExtensionContent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionContent.sys.mjs" + ); + // Save context so we can verify that contentWindow is nulled after unload. + this.context = ExtensionContent.getContextByExtensionId( + extensionId, + this.content + ); + + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property is correct" + ); + + this.contextUnloadedPromise = new Promise(resolve => { + this.context.callOnClose({ close: resolve }); + }); + this.pageshownPromise = new Promise(resolve => { + this.content.addEventListener( + "pageshow", + () => { + // Yield to the event loop once more to ensure that all pageshow event + // handlers have been dispatched before fulfilling the promise. + let { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" + ); + setTimeout(resolve, 0); + }, + { once: true, mozSystemGroup: true } + ); + }); + + // Navigate so that the content page is hidden in the bfcache. + this.content.location = "http://example.org/dummy?second"; + }); + + await extension.awaitMessage("content-script-hide"); + + await extension.unload(); + await contentPage.legacySpawn(null, async () => { + await this.contextUnloadedPromise; + Assert.equal(this.context.unloaded, true, "Context has been unloaded"); + + // Normally, when a page is not in the bfcache, context.contentWindow is + // not null when the callOnClose handler is invoked (this is checked by the + // previous subtest). + // Now wait a little bit and check again to ensure that the contentWindow + // property is not somehow restored. + await new Promise(resolve => this.content.setTimeout(resolve, 0)); + Assert.equal( + this.context.contentWindow, + null, + "Context's contentWindow property is null" + ); + + // Navigate back so the content page is resurrected from the bfcache. + this.content.history.back(); + + await this.pageshownPromise; + + Assert.equal( + this.context.contentWindow, + null, + "Context's contentWindow property is null after restore from bfcache" + ); + }); + + await contentPage.close(); +}); + +add_task(async function test_contentscript_context_valid_during_execution() { + // This test does the following: + // - Load page + // - Load extension; inject content script. + // - Navigate page; pagehide triggered. + // - Navigate back; pageshow triggered. + // - Close page; pagehide, unload triggered. + // At each of these last four events, the validity of the context is checked. + + function contentScript() { + browser.test.sendMessage("content-script-ready"); + window.wrappedJSObject.checkContextIsValid("Context is valid on execution"); + + window.addEventListener( + "pagehide", + () => { + window.wrappedJSObject.checkContextIsValid( + "Context is valid on pagehide" + ); + browser.test.sendMessage("content-script-hide"); + }, + true + ); + window.addEventListener("pageshow", () => { + window.wrappedJSObject.checkContextIsValid( + "Context is valid on pageshow" + ); + + // This unload listener is registered after pageshow, to ensure that the + // page can be stored in the bfcache at the previous pagehide. + window.addEventListener("unload", () => { + window.wrappedJSObject.checkContextIsValid( + "Context is valid on unload" + ); + browser.test.sendMessage("content-script-unload"); + }); + + browser.test.sendMessage("content-script-show"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy*"], + js: ["content_script.js"], + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy?first" + ); + await contentPage.legacySpawn(extension.id, async extensionId => { + let context; + let checkContextIsValid = description => { + if (!context) { + const { ExtensionContent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionContent.sys.mjs" + ); + context = ExtensionContent.getContextByExtensionId( + extensionId, + this.content + ); + } + Assert.equal( + context.contentWindow, + this.content, + `${description}: contentWindow` + ); + Assert.equal(context.active, true, `${description}: active`); + }; + Cu.exportFunction(checkContextIsValid, this.content, { + defineAs: "checkContextIsValid", + }); + }); + await extension.startup(); + await extension.awaitMessage("content-script-ready"); + + await contentPage.legacySpawn(extension.id, async extensionId => { + // Navigate so that the content page is frozen in the bfcache. + this.content.location = "http://example.org/dummy?second"; + }); + + await extension.awaitMessage("content-script-hide"); + await contentPage.legacySpawn(null, async () => { + // Navigate back so the content page is resurrected from the bfcache. + this.content.history.back(); + }); + + await extension.awaitMessage("content-script-show"); + await contentPage.close(); + await extension.awaitMessage("content-script-hide"); + await extension.awaitMessage("content-script-unload"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context_isolation.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context_isolation.js new file mode 100644 index 0000000000..7794a66d57 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context_isolation.js @@ -0,0 +1,168 @@ +"use strict"; + +/* globals exportFunction */ +/* eslint-disable mozilla/balanced-listeners */ + +const server = createHttpServer({ hosts: ["example.com", "example.org"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write(""); +}); + +server.registerPathHandler("/bfcachetestpage", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html;charset=utf-8", false); + response.write(` +`); +}); + +add_task(async function test_contentscript_context_isolation() { + function contentScript() { + browser.test.sendMessage("content-script-ready"); + + exportFunction(browser.test.sendMessage, window, { + defineAs: "browserTestSendMessage", + }); + + window.addEventListener("pageshow", () => { + browser.test.fail( + "pageshow should have been suppressed by stopImmediatePropagation" + ); + }); + window.addEventListener( + "pagehide", + () => { + browser.test.fail( + "pagehide should have been suppressed by stopImmediatePropagation" + ); + }, + true + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/bfcachetestpage"], + js: ["content_script.js"], + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/bfcachetestpage" + ); + await extension.startup(); + await extension.awaitMessage("content-script-ready"); + + // Get the content script context and check that it points to the correct window. + await contentPage.legacySpawn(extension.id, async extensionId => { + const { ExtensionContent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionContent.sys.mjs" + ); + this.context = ExtensionContent.getContextByExtensionId( + extensionId, + this.content + ); + + Assert.ok(this.context, "Got content script context"); + + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property is correct" + ); + + // Navigate so that the content page is hidden in the bfcache. + + this.content.location = "http://example.org/dummy?noscripthere1"; + }); + + await extension.awaitMessage("content-script-hide"); + + await contentPage.legacySpawn(null, async () => { + Assert.equal( + this.context.contentWindow, + null, + "Context's contentWindow property is null" + ); + Assert.ok(this.context.sandbox, "Context's sandbox exists"); + + // Navigate back so the content page is resurrected from the bfcache. + this.content.history.back(); + }); + + await extension.awaitMessage("content-script-show"); + + async function testWithoutBfcache() { + return contentPage.legacySpawn(null, async () => { + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property is correct" + ); + Assert.ok(this.context.sandbox, "Context's sandbox exists before unload"); + + let contextUnloadedPromise = new Promise(resolve => { + this.context.callOnClose({ close: resolve }); + }); + + // Now add an "unload" event listener, which should prevent a page from entering the bfcache. + await new Promise(resolve => { + this.content.addEventListener("unload", () => { + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property should be non-null at unload" + ); + resolve(); + }); + this.content.location = "http://example.org/dummy?noscripthere2"; + }); + + await contextUnloadedPromise; + }); + } + await runWithPrefs( + [["docshell.shistory.bfcache.allow_unload_listeners", false]], + testWithoutBfcache + ); + + await extension.awaitMessage("content-script-unload"); + + await contentPage.legacySpawn(null, async () => { + Assert.equal( + this.context.sandbox, + null, + "Context's sandbox has been destroyed after unload" + ); + }); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_create_iframe.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_create_iframe.js new file mode 100644 index 0000000000..41d9901c80 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_create_iframe.js @@ -0,0 +1,177 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_contentscript_create_iframe() { + function background() { + browser.runtime.onMessage.addListener((msg, sender) => { + let { name, availableAPIs, manifest, testGetManifest } = msg; + let hasExtTabsAPI = availableAPIs.indexOf("tabs") > 0; + let hasExtWindowsAPI = availableAPIs.indexOf("windows") > 0; + + browser.test.assertFalse( + hasExtTabsAPI, + "the created iframe should not be able to use privileged APIs (tabs)" + ); + browser.test.assertFalse( + hasExtWindowsAPI, + "the created iframe should not be able to use privileged APIs (windows)" + ); + + let { + browser_specific_settings: { + gecko: { id: expectedManifestGeckoId }, + }, + } = chrome.runtime.getManifest(); + let { + browser_specific_settings: { + gecko: { id: actualManifestGeckoId }, + }, + } = manifest; + + browser.test.assertEq( + actualManifestGeckoId, + expectedManifestGeckoId, + "the add-on manifest should be accessible from the created iframe" + ); + + let { + browser_specific_settings: { + gecko: { id: testGetManifestGeckoId }, + }, + } = testGetManifest; + + browser.test.assertEq( + testGetManifestGeckoId, + expectedManifestGeckoId, + "GET_MANIFEST() returns manifest data before extension unload" + ); + + browser.test.sendMessage(name); + }); + } + + function contentScriptIframe() { + window.GET_MANIFEST = browser.runtime.getManifest.bind(null); + + window.testGetManifestException = () => { + try { + window.GET_MANIFEST(); + } catch (exception) { + return String(exception); + } + }; + + let testGetManifest = window.GET_MANIFEST(); + + let manifest = browser.runtime.getManifest(); + let availableAPIs = Object.keys(browser).filter(key => browser[key]); + + browser.runtime.sendMessage({ + name: "content-script-iframe-loaded", + availableAPIs, + manifest, + testGetManifest, + }); + } + + const ID = "contentscript@tests.mozilla.org"; + let extensionData = { + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + web_accessible_resources: ["content_script_iframe.html"], + }, + + background, + + files: { + "content_script.js"() { + let iframe = document.createElement("iframe"); + iframe.src = browser.runtime.getURL("content_script_iframe.html"); + document.body.appendChild(iframe); + }, + "content_script_iframe.html": ` + + + + + + `, + "content_script_iframe.js": contentScriptIframe, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + await extension.awaitMessage("content-script-iframe-loaded"); + + info("testing APIs availability once the extension is unloaded..."); + + await contentPage.legacySpawn(null, () => { + this.iframeWindow = this.content[0]; + + Assert.ok(this.iframeWindow, "content script enabled iframe found"); + Assert.ok( + /content_script_iframe\.html$/.test(this.iframeWindow.location), + "the found iframe has the expected URL" + ); + }); + + await extension.unload(); + + info( + "test content script APIs not accessible from the frame once the extension is unloaded" + ); + + await contentPage.legacySpawn(null, () => { + let win = Cu.waiveXrays(this.iframeWindow); + ok( + !Cu.isDeadWrapper(win.browser), + "the API object should not be a dead object" + ); + + let manifest; + let manifestException; + try { + manifest = win.browser.runtime.getManifest(); + } catch (e) { + manifestException = e; + } + + Assert.ok(!manifest, "manifest should be undefined"); + + Assert.equal( + manifestException.constructor.name, + "TypeError", + "expected exception received" + ); + + Assert.ok( + manifestException.message.endsWith("win.browser.runtime is undefined"), + "expected exception received" + ); + + let getManifestException = win.testGetManifestException(); + + Assert.equal( + getManifestException, + "TypeError: can't access dead object", + "expected exception received" + ); + }); + + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js new file mode 100644 index 0000000000..6b03f5b0b0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js @@ -0,0 +1,433 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ + hosts: ["example.com", "csplog.example.net"], +}); +server.registerDirectory("/data/", do_get_file("data")); + +var gDefaultCSP = `default-src 'self' 'report-sample'; script-src 'self' 'report-sample';`; +var gCSP = gDefaultCSP; +const pageContent = ` + + + + + + + + + `; + +server.registerPathHandler("/plain.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html"); + if (gCSP) { + info(`Content-Security-Policy: ${gCSP}`); + response.setHeader("Content-Security-Policy", gCSP); + } + response.write(pageContent); +}); + +const BASE_URL = `http://example.com`; +const pageURL = `${BASE_URL}/plain.html`; + +const CSP_REPORT_PATH = "/csp-report.sjs"; + +function readUTF8InputStream(stream) { + let buffer = NetUtil.readInputStream(stream, stream.available()); + return new TextDecoder().decode(buffer); +} + +server.registerPathHandler(CSP_REPORT_PATH, (request, response) => { + response.setStatusLine(request.httpVersion, 204, "No Content"); + let data = readUTF8InputStream(request.bodyInputStream); + Services.obs.notifyObservers(null, "extension-test-csp-report", data); +}); + +async function promiseCSPReport(test) { + let res = await TestUtils.topicObserved("extension-test-csp-report", test); + return JSON.parse(res[1]); +} + +// Test functions loaded into extension content script. +function testImage(data = {}) { + return new Promise(resolve => { + let img = window.document.getElementById("testimg"); + img.onload = () => resolve(true); + img.onerror = () => { + browser.test.log(`img error: ${img.src}`); + resolve(false); + }; + img.src = data.image_url; + }); +} + +function testFetch(data = {}) { + let f = data.content ? content.fetch : fetch; + return f(data.url) + .then(() => true) + .catch(e => { + browser.test.assertEq( + e.message, + "NetworkError when attempting to fetch resource.", + "expected fetch failure" + ); + return false; + }); +} + +async function testEval(data = {}) { + try { + // eslint-disable-next-line no-eval + let ev = data.content ? window.eval : eval; + return ev("true"); + } catch (e) { + return false; + } +} + +async function testFunction(data = {}) { + try { + // eslint-disable-next-line no-eval + let fn = data.content ? window.Function : Function; + let sum = new fn("a", "b", "return a + b"); + return sum(1, 1); + } catch (e) { + return 0; + } +} + +function testScriptTag(data) { + return new Promise(resolve => { + let script = document.createElement("script"); + script.src = data.url; + script.onload = () => { + resolve(true); + }; + script.onerror = () => { + resolve(false); + }; + document.body.appendChild(script); + }); +} + +async function testHttpRequestUpgraded(data = {}) { + let f = data.content ? content.fetch : fetch; + return f(data.url) + .then(() => "http:") + .catch(() => "https:"); +} + +async function testWebSocketUpgraded(data = {}) { + let ws = data.content ? content.WebSocket : WebSocket; + new ws(data.url); +} + +function webSocketUpgradeListenerBackground() { + // Catch websocket requests and send the protocol back to be asserted. + browser.webRequest.onBeforeRequest.addListener( + details => { + // Send the protocol back as test result. + // This will either be "wss:", "ws:" + browser.test.sendMessage("result", new URL(details.url).protocol); + return { cancel: true }; + }, + { urls: ["wss://example.com/*", "ws://example.com/*"] }, + ["blocking"] + ); +} + +// If the violation source is the extension the securitypolicyviolation event is not fired. +// If the page is the source, the event is fired and both the content script or page scripts +// will receive the event. If we're expecting a moz-extension report we'll fail in the +// event listener if we receive a report. Otherwise we want to resolve in the listener to +// ensure we've received the event for the test. +function contentScript(report) { + return new Promise(resolve => { + if (!report || report["document-uri"] === "moz-extension") { + resolve(); + } + // eslint-disable-next-line mozilla/balanced-listeners + document.addEventListener("securitypolicyviolation", e => { + browser.test.assertTrue( + e.documentURI !== "moz-extension", + `securitypolicyviolation: ${e.violatedDirective} ${e.documentURI}` + ); + resolve(); + }); + }); +} + +let TESTS = [ + // Image Tests + { + description: + "Image from content script using default extension csp. Image is allowed.", + pageCSP: `${gDefaultCSP} img-src 'none';`, + script: testImage, + data: { image_url: `${BASE_URL}/data/file_image_good.png` }, + expect: true, + }, + // Fetch Tests + { + description: "Fetch url in content script uses default extension csp.", + pageCSP: `${gDefaultCSP} connect-src 'none';`, + script: testFetch, + data: { url: `${BASE_URL}/data/file_image_good.png` }, + expect: true, + }, + { + description: "Fetch full url from content script uses page csp.", + pageCSP: `${gDefaultCSP} connect-src 'none';`, + script: testFetch, + data: { + content: true, + url: `${BASE_URL}/data/file_image_good.png`, + }, + expect: false, + report: { + "blocked-uri": `${BASE_URL}/data/file_image_good.png`, + "document-uri": `${BASE_URL}/plain.html`, + "violated-directive": "connect-src", + }, + }, + + // Eval tests. + { + description: "Eval from content script uses page csp with unsafe-eval.", + pageCSP: `default-src 'none'; script-src 'unsafe-eval';`, + script: testEval, + data: { content: true }, + expect: true, + }, + { + description: "Eval from content script uses page csp.", + pageCSP: `default-src 'self' 'report-sample'; script-src 'self';`, + version: 3, + script: testEval, + data: { content: true }, + expect: false, + report: { + "blocked-uri": "eval", + "document-uri": "http://example.com/plain.html", + "violated-directive": "script-src", + }, + }, + { + description: "Eval in content script allowed by v2 csp.", + pageCSP: `script-src 'self' 'unsafe-eval';`, + script: testEval, + expect: true, + }, + { + description: "Eval in content script disallowed by v3 csp.", + pageCSP: `script-src 'self' 'unsafe-eval';`, + version: 3, + script: testEval, + expect: false, + }, + { + description: "Wrapped Eval in content script uses page csp.", + pageCSP: `script-src 'self' 'unsafe-eval';`, + version: 3, + script: async () => { + return window.wrappedJSObject.eval("true"); + }, + expect: true, + }, + { + description: "Wrapped Eval in content script denied by page csp.", + pageCSP: `script-src 'self';`, + version: 3, + script: async () => { + try { + return window.wrappedJSObject.eval("true"); + } catch (e) { + return false; + } + }, + expect: false, + }, + + { + description: "Function from content script uses page csp.", + pageCSP: `default-src 'self'; script-src 'self' 'unsafe-eval';`, + script: testFunction, + data: { content: true }, + expect: 2, + }, + { + description: "Function from content script uses page csp.", + pageCSP: `default-src 'self' 'report-sample'; script-src 'self';`, + version: 3, + script: testFunction, + data: { content: true }, + expect: 0, + report: { + "blocked-uri": "eval", + "document-uri": "http://example.com/plain.html", + "violated-directive": "script-src", + }, + }, + { + description: "Function in content script uses extension csp.", + pageCSP: `default-src 'self'; script-src 'self' 'unsafe-eval';`, + version: 3, + script: testFunction, + expect: 0, + }, + + // The javascript url tests are not included as we do not execute those, + // aparently even with the urlbar filtering pref flipped. + // (browser.urlbar.filter.javascript) + // https://bugzilla.mozilla.org/show_bug.cgi?id=866522 + + // script tag injection tests + { + description: "remote script in content script passes in v2", + version: 2, + pageCSP: "script-src http://example.com:*;", + script: testScriptTag, + data: { url: `${BASE_URL}/data/file_script_good.js` }, + expect: true, + }, + { + description: "remote script in content script fails in v3", + version: 3, + pageCSP: "script-src http://example.com:*;", + script: testScriptTag, + data: { url: `${BASE_URL}/data/file_script_good.js` }, + expect: false, + }, + { + description: "content.WebSocket in content script is affected by page csp.", + version: 2, + pageCSP: `upgrade-insecure-requests;`, + data: { content: true, url: "ws://example.com/ws_dummy" }, + script: testWebSocketUpgraded, + expect: "wss:", // we expect the websocket to be upgraded. + backgroundScript: webSocketUpgradeListenerBackground, + }, + { + description: "WebSocket in content script is not affected by page csp.", + version: 2, + pageCSP: `upgrade-insecure-requests;`, + data: { url: "ws://example.com/ws_dummy" }, + script: testWebSocketUpgraded, + expect: "ws:", // we expect the websocket to not be upgraded. + backgroundScript: webSocketUpgradeListenerBackground, + }, + { + description: "WebSocket in content script is not affected by page csp. v3", + version: 3, + pageCSP: `upgrade-insecure-requests;`, + data: { url: "ws://example.com/ws_dummy" }, + script: testWebSocketUpgraded, + // TODO bug 1766813: MV3+WebSocket should use content script CSP. + expect: "wss:", // TODO: we expect the websocket to not be upgraded (ws:). + backgroundScript: webSocketUpgradeListenerBackground, + }, + { + description: "Http request in content script is not affected by page csp.", + version: 2, + pageCSP: `upgrade-insecure-requests;`, + data: { url: "http://example.com/plain.html" }, + script: testHttpRequestUpgraded, + expect: "http:", // we expect the request to not be upgraded. + }, + { + description: + "Http request in content script is not affected by page csp. v3", + version: 3, + pageCSP: `upgrade-insecure-requests;`, + data: { url: "http://example.com/plain.html" }, + script: testHttpRequestUpgraded, + // TODO bug 1766813: MV3+fetch should use content script CSP. + expect: "https:", // TODO: we expect the request to not be upgraded (http:). + }, + { + description: "content.fetch in content script is affected by page csp.", + version: 2, + pageCSP: `upgrade-insecure-requests;`, + data: { content: true, url: "http://example.com/plain.html" }, + script: testHttpRequestUpgraded, + expect: "https:", // we expect the request to be upgraded. + }, +]; + +async function runCSPTest(test) { + // Set the CSP for the page loaded into the tab. + gCSP = `${test.pageCSP || gDefaultCSP} report-uri ${CSP_REPORT_PATH}`; + let data = { + manifest: { + manifest_version: test.version || 2, + content_scripts: [ + { + matches: ["http://*/plain.html"], + run_at: "document_idle", + js: ["content_script.js"], + }, + ], + permissions: ["webRequest", "webRequestBlocking"], + host_permissions: [""], + granted_host_permissions: true, + background: { scripts: ["background.js"] }, + }, + temporarilyInstalled: true, + files: { + "content_script.js": ` + (${contentScript})(${JSON.stringify(test.report)}).then(() => { + browser.test.sendMessage("violationEvent"); + }); + (${test.script})(${JSON.stringify(test.data)}).then(result => { + if(result !== undefined) { + browser.test.sendMessage("result", result); + } + }); + `, + "background.js": `(${test.backgroundScript || (() => {})})()`, + ...test.files, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(data); + await extension.startup(); + + let reportPromise = test.report && promiseCSPReport(); + let contentPage = await ExtensionTestUtils.loadContentPage(pageURL); + + info(`running: ${test.description}`); + await extension.awaitMessage("violationEvent"); + + let result = await extension.awaitMessage("result"); + equal(result, test.expect, test.description); + + if (test.report) { + let report = await reportPromise; + for (let key of Object.keys(test.report)) { + equal( + report["csp-report"][key], + test.report[key], + `csp-report ${key} matches` + ); + } + } + + await extension.unload(); + await contentPage.close(); + clearCache(); +} + +add_task(async function test_contentscript_csp() { + for (let test of TESTS) { + await runCSPTest(test); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_css.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_css.js new file mode 100644 index 0000000000..a75c397b8c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_css.js @@ -0,0 +1,48 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write(""); +}); + +add_task(async function test_content_script_css() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy"], + css: ["content.css"], + run_at: "document_start", + }, + ], + }, + + files: { + "content.css": "body { max-width: 42px; }", + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + + function task() { + let style = this.content.getComputedStyle(this.content.document.body); + return style.maxWidth; + } + + let maxWidth = await contentPage.spawn([], task); + equal(maxWidth, "42px", "Stylesheet correctly applied"); + + await extension.unload(); + + maxWidth = await contentPage.spawn([], task); + equal(maxWidth, "none", "Stylesheet correctly removed"); + + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_dynamic_registration.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_dynamic_registration.js new file mode 100644 index 0000000000..0133b5d86c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_dynamic_registration.js @@ -0,0 +1,205 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, to use +// the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +// Do not use preallocated processes. +Services.prefs.setBoolPref("dom.ipc.processPrelaunch.enabled", false); +// This is needed for Android. +Services.prefs.setIntPref("dom.ipc.keepProcessesAlive.web", 0); + +const makeExtension = ({ background, manifest }) => { + return ExtensionTestUtils.loadExtension({ + manifest: { + ...manifest, + permissions: + manifest.manifest_version === 3 ? ["scripting"] : ["http://*/*/*.html"], + }, + temporarilyInstalled: true, + background, + files: { + "script.js": () => { + browser.test.sendMessage( + `script-ran: ${location.pathname.split("/").pop()}` + ); + }, + "inject_browser.js": () => { + browser.userScripts.onBeforeScript.addListener(script => { + // Inject `browser.test.sendMessage()` so that it can be used in the + // `script.js` defined above when using "user scripts". + script.defineGlobals({ + browser: { + test: { + sendMessage(msg) { + browser.test.sendMessage(msg); + }, + }, + }, + }); + }); + }, + }, + }); +}; + +const verifyRegistrationWithNewProcess = async extension => { + // We override the `broadcast()` method to reliably verify Bug 1756495: when + // a new process is spawned while we register a content script, the script + // should be correctly registered and executed in this new process. Below, + // when we receive the `Extension:RegisterContentScripts`, we open a new tab + // (which is the "new process") and then we invoke the original "broadcast + // logic". The expected result is that the content script registered by the + // extension will run. + const originalBroadcast = Extension.prototype.broadcast; + + let broadcastCalledCount = 0; + let secondContentPage; + + extension.extension.broadcast = async function broadcast(msg, data) { + if (msg !== "Extension:RegisterContentScripts") { + return originalBroadcast.call(this, msg, data); + } + + broadcastCalledCount++; + Assert.equal( + 1, + broadcastCalledCount, + "broadcast override should be called once" + ); + + await originalBroadcast.call(this, msg, data); + + Assert.equal(extension.id, data.id, "got expected extension ID"); + Assert.equal(1, data.scripts.length, "expected 1 script to register"); + Assert.ok( + data.scripts[0].options.jsPaths[0].endsWith("script.js"), + "got expected js file" + ); + + const newPids = []; + const topic = "ipc:content-created"; + + let obs = (subject, topic, data) => { + newPids.push(parseInt(data, 10)); + }; + Services.obs.addObserver(obs, topic); + + secondContentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/dummy_page.html` + ); + + const { childID } = + secondContentPage.browsingContext.currentWindowGlobal.domProcess; + + Services.obs.removeObserver(obs, topic); + + // We expect to have a new process created for `secondContentPage`. + Assert.ok( + newPids.includes(childID), + `expected PID ${childID} to be in [${newPids.join(", ")}])` + ); + }; + + await extension.startup(); + await extension.awaitMessage("background-done"); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await Promise.all([ + extension.awaitMessage("script-ran: file_sample.html"), + extension.awaitMessage("script-ran: dummy_page.html"), + ]); + + // Unload extension first to avoid an issue on Windows platforms. + await extension.unload(); + await contentPage.close(); + await secondContentPage.close(); +}; + +add_task( + { + pref_set: [["extensions.manifestV3.enabled", true]], + }, + async function test_scripting_registerContentScripts() { + let extension = makeExtension({ + manifest: { + manifest_version: 3, + host_permissions: [""], + granted_host_permissions: true, + }, + async background() { + const script = { + id: "a-script", + js: ["script.js"], + matches: ["http://*/*/*.html"], + persistAcrossSessions: false, + }; + + await browser.scripting.registerContentScripts([script]); + + browser.test.sendMessage("background-done"); + }, + }); + + await verifyRegistrationWithNewProcess(extension); + } +); + +add_task( + { + // We don't have WebIDL bindings for `browser.contentScripts`. + skip_if: () => ExtensionTestUtils.isInBackgroundServiceWorkerTests(), + }, + async function test_contentScripts_register() { + let extension = makeExtension({ + manifest: { + manifest_version: 2, + }, + async background() { + await browser.contentScripts.register({ + js: [{ file: "script.js" }], + matches: ["http://*/*/*.html"], + }); + + browser.test.sendMessage("background-done"); + }, + }); + + await verifyRegistrationWithNewProcess(extension); + } +); + +add_task( + { + // We don't have WebIDL bindings for `browser.userScripts`. + skip_if: () => ExtensionTestUtils.isInBackgroundServiceWorkerTests(), + }, + async function test_userScripts_register() { + let extension = makeExtension({ + manifest: { + manifest_version: 2, + user_scripts: { + api_script: "inject_browser.js", + }, + }, + async background() { + await browser.userScripts.register({ + js: [{ file: "script.js" }], + matches: ["http://*/*/*.html"], + }); + + browser.test.sendMessage("background-done"); + }, + }); + + await verifyRegistrationWithNewProcess(extension); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_errors.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_errors.js new file mode 100644 index 0000000000..6fae3b838a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_errors.js @@ -0,0 +1,150 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; +const TEST_URL_1 = `${BASE_URL}/file_sample.html`; +const TEST_URL_2 = `${BASE_URL}/file_content_script_errors.html`; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +add_task(async function test_cached_contentscript_on_document_start() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + // Use distinct content scripts as some will throw and would prevent executing the next script + { + matches: ["http://*/*/file_content_script_errors.html"], + js: ["script1.js"], + run_at: "document_start", + }, + { + matches: ["http://*/*/file_content_script_errors.html"], + js: ["script2.js"], + run_at: "document_start", + }, + { + matches: ["http://*/*/file_content_script_errors.html"], + js: ["script3.js"], + run_at: "document_start", + }, + { + matches: ["http://*/*/file_content_script_errors.html"], + js: ["script4.js"], + run_at: "document_start", + }, + { + matches: ["http://*/*/file_content_script_errors.html"], + js: ["script5.js"], + run_at: "document_start", + }, + ], + }, + + files: { + "script1.js": ` + throw new Error("Object exception"); + `, + "script2.js": ` + throw "String exception"; + `, + "script3.js": ` + undefinedSymbol(); + `, + "script4.js": ` + ) + `, + "script5.js": ` + Promise.reject("rejected promise"); + + (async () => { + /* make the async, really async */ + await new Promise(r => setTimeout(r, 0)); + throw new Error("async function exception"); + })(); + + setTimeout(() => { + asyncUndefinedSymbol(); + }); + + /* Use a delay in order to resume test execution after these async errors */ + setTimeout(() => { + browser.test.sendMessage("content-script-loaded"); + }, 500); + `, + }, + }); + + // Error messages, in roughly the order they appear above. + let expectedMessages = [ + "Error: Object exception", + "uncaught exception: String exception", + "ReferenceError: undefinedSymbol is not defined", + "SyntaxError: expected expression, got ')'", + "uncaught exception: rejected promise", + "Error: async function exception", + "ReferenceError: asyncUndefinedSymbol is not defined", + ]; + + await extension.startup(); + + // Load a first page in order to be able to register a console listener in the content process. + // This has to be done in the same domain of the second page to stay in the same process. + let contentPage = await ExtensionTestUtils.loadContentPage(TEST_URL_1); + + // Listen to the errors logged in the content process. + let errorsPromise = ContentTask.spawn(contentPage.browser, {}, async () => { + return new Promise(resolve => { + function listener(error0) { + let error = error0.QueryInterface(Ci.nsIScriptError); + + // Ignore errors from ExtensionContent.jsm + if (!error.innerWindowID) { + return; + } + + this.collectedErrors.push({ + innerWindowID: error.innerWindowID, + message: error.errorMessage, + }); + if (this.collectedErrors.length == 7) { + Services.console.unregisterListener(this); + resolve(this.collectedErrors); + } + } + listener.collectedErrors = []; + Services.console.registerListener(listener); + }); + }); + + // Reload the page and check that the cached content script is still able to + // run on document_start. + await contentPage.loadURL(TEST_URL_2); + + let errors = await errorsPromise; + + await extension.awaitMessage("content-script-loaded"); + + equal(errors.length, 7); + let messages = []; + for (const { innerWindowID, message } of errors) { + equal( + innerWindowID, + contentPage.browser.innerWindowID, + `Message ${message} has the innerWindowID set` + ); + + messages.push(message); + } + + messages.sort(); + expectedMessages.sort(); + Assert.deepEqual(messages, expectedMessages, "Got the expected errors"); + + await extension.unload(); + + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_exporthelpers.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_exporthelpers.js new file mode 100644 index 0000000000..ec3b11ee7d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_exporthelpers.js @@ -0,0 +1,98 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_contentscript_exportHelpers() { + function contentScript() { + browser.test.assertTrue(typeof cloneInto === "function"); + browser.test.assertTrue(typeof createObjectIn === "function"); + browser.test.assertTrue(typeof exportFunction === "function"); + + /* globals exportFunction, precisePi, reportPi */ + let value = 3.14; + exportFunction(() => value, window, { defineAs: "precisePi" }); + + browser.test.assertEq( + "undefined", + typeof precisePi, + "exportFunction should export to the page's scope only" + ); + + browser.test.assertEq( + "undefined", + typeof window.precisePi, + "exportFunction should export to the page's scope only" + ); + + let results = []; + exportFunction(pi => results.push(pi), window, { defineAs: "reportPi" }); + + let s = document.createElement("script"); + s.textContent = `(${function () { + let result1 = "unknown 1"; + let result2 = "unknown 2"; + try { + result1 = precisePi(); + } catch (e) { + result1 = "err:" + e; + } + try { + result2 = window.precisePi(); + } catch (e) { + result2 = "err:" + e; + } + reportPi(result1); + reportPi(result2); + }})();`; + + document.documentElement.appendChild(s); + // Inline script ought to run synchronously. + + browser.test.assertEq( + 3.14, + results[0], + "exportFunction on window should define a global function" + ); + browser.test.assertEq( + 3.14, + results[1], + "exportFunction on window should export a property to window." + ); + + browser.test.assertEq( + 2, + results.length, + "Expecting the number of results to match the number of method calls" + ); + + browser.test.notifyPass("export helper test completed"); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + js: ["contentscript.js"], + matches: ["http://example.com/data/file_sample.html"], + run_at: "document_start", + }, + ], + }, + + files: { + "contentscript.js": contentScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + await extension.awaitFinish("export helper test completed"); + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_importmap.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_importmap.js new file mode 100644 index 0000000000..ba7f7120d9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_importmap.js @@ -0,0 +1,123 @@ +"use strict"; + +// Currently import maps are not supported for web extensions, neither for +// content scripts nor moz-extension documents. +// For content scripts that's because they use their own sandbox module loaders, +// which is different from the DOM module loader. +// As for moz-extension documents, that's because inline script tags is not +// allowed by CSP. (Currently import maps can be only added through inline +// script tag.) +// +// This test is used to verified import maps are not supported for web +// extensions. +// See Bug 1765275: Enable Import maps for web extension content scripts. + +const server = createHttpServer({ hosts: ["example.com"] }); + +const importMapString = ` + `; + +const importMapHtml = ` + + + + Test a simple import map in normal webpage + + ${importMapString} + `; + +// page.html will load page.js, which will call import(); +const pageHtml = ` + + + + Test a simple import map in moz-extension documents + + ${importMapString} + + `; + +const simple2JS = `export let foo = 2;`; + +server.registerPathHandler("/importmap.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write(importMapHtml); +}); + +server.registerPathHandler("/simple.js", (request, response) => { + ok(false, "Unexpected request to /simple.js"); +}); + +server.registerPathHandler("/simple2.js", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/javascript", false); + response.write(simple2JS); +}); + +add_task(async function test_importMaps_not_supported() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/importmap.html"], + js: ["main.js"], + }, + ], + }, + + files: { + "main.js": async function () { + // Content scripts shouldn't be able to use the bare specifier from + // the import map. + await browser.test.assertRejects( + import("simple"), + /The specifier “simple” was a bare specifier/, + `should reject import("simple")` + ); + + browser.test.sendMessage("done"); + }, + "page.html": pageHtml, + "page.js": async function () { + await browser.test.assertRejects( + import("simple"), + /The specifier “simple” was a bare specifier/, + `should reject import("simple")` + ); + browser.test.sendMessage("page-done"); + }, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/importmap.html" + ); + await extension.awaitMessage("done"); + + await contentPage.spawn([], async () => { + // Import maps should work for documents. + let promise = content.eval(`import("simple2")`); + let mod = (await promise.wrappedJSObject).wrappedJSObject; + Assert.equal(mod.foo, 2, "mod.foo should be 2"); + }); + + // moz-extension documents doesn't allow inline scripts, so the import map + // script tag won't be processed. + let url = `moz-extension://${extension.uuid}/page.html`; + let page = await ExtensionTestUtils.loadContentPage(url, { extension }); + await extension.awaitMessage("page-done"); + + await page.close(); + await extension.unload(); + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_in_background.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_in_background.js new file mode 100644 index 0000000000..15c5b30542 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_in_background.js @@ -0,0 +1,48 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/dummyFrame", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write(""); +}); + +add_task(async function content_script_in_background_frame() { + // Test loads http: frame in background page. + allow_unsafe_parent_loads_when_extensions_not_remote(); + + async function background() { + const FRAME_URL = "http://example.com:8888/dummyFrame"; + await browser.contentScripts.register({ + matches: ["http://example.com/dummyFrame"], + js: [{ file: "contentscript.js" }], + allFrames: true, + }); + + let f = document.createElement("iframe"); + f.src = FRAME_URL; + document.body.appendChild(f); + } + + function contentScript() { + browser.test.log(`Running content script at ${document.URL}`); + browser.test.sendMessage("done_in_content_script"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.com/*"], + }, + files: { + "contentscript.js": contentScript, + }, + background, + }); + await extension.startup(); + await extension.awaitMessage("done_in_content_script"); + await extension.unload(); + + revert_allow_unsafe_parent_loads_when_extensions_not_remote(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_json_api.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_json_api.js new file mode 100644 index 0000000000..ca37e2e951 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_json_api.js @@ -0,0 +1,102 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write( + ` + ` + ); +}); + +async function test_JSON_parse_and_stringify({ manifest_version }) { + let extension = ExtensionTestUtils.loadExtension({ + temporarilyInstalled: true, // Needed for granted_host_permissions + manifest: { + manifest_version, + granted_host_permissions: true, // Test-only: grant permissions in MV3. + host_permissions: ["http://example.com/"], // Work-around for bug 1766752. + content_scripts: [ + { + matches: ["http://example.com/dummy"], + run_at: "document_end", + js: ["contentscript.js"], + }, + ], + }, + files: { + "contentscript.js"() { + let json = `{"a":[123,true,null]}`; + browser.test.assertEq( + JSON.stringify({ a: [123, true, null] }), + json, + "JSON.stringify with basic values" + ); + let parsed = JSON.parse(json); + browser.test.assertTrue( + parsed instanceof Object, + "Parsed JSON is an Object" + ); + browser.test.assertTrue( + parsed.a instanceof Array, + "Parsed JSON has an Array" + ); + browser.test.assertEq( + JSON.stringify(parsed), + json, + "JSON.stringify for parsed JSON returns original input" + ); + browser.test.assertEq( + JSON.stringify({ toJSON: () => "overridden", hideme: true }), + `"overridden"`, + "JSON.parse with toJSON method" + ); + + browser.test.assertEq( + JSON.stringify(window.wrappedJSObject.objFromPage), + `{"serializeMe":"thanks"}`, + "JSON.parse with value from the page" + ); + + browser.test.assertEq( + JSON.stringify(window.wrappedJSObject.objWithToJSON), + `"toJSON ran"`, + "JSON.parse with object with toJSON method from the page" + ); + + browser.test.assertTrue(JSON === globalThis.JSON, "JSON === this.JSON"); + browser.test.assertTrue(JSON === window.JSON, "JSON === window.JSON"); + browser.test.assertEq( + "overridden by page", + window.wrappedJSObject.JSON.toString(), + "page's JSON object is still the original value (overridden by page)" + ); + browser.test.sendMessage("done"); + }, + }, + }); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitMessage("done"); + await contentPage.close(); + await extension.unload(); +} + +add_task(async function test_JSON_apis_MV2() { + await test_JSON_parse_and_stringify({ manifest_version: 2 }); +}); + +add_task(async function test_JSON_apis_MV3() { + await test_JSON_parse_and_stringify({ manifest_version: 3 }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_module_import.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_module_import.js new file mode 100644 index 0000000000..3e4e5dd983 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_module_import.js @@ -0,0 +1,277 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write(""); +}); + +server.registerPathHandler("/script.js", (request, response) => { + ok(false, "Unexpected request to /script.js"); +}); + +/* eslint-disable no-eval, no-implied-eval */ + +const MODULE1 = ` + import {foo} from "./module2.js"; + export let bar = foo; + + let count = 0; + + export function counter () { return count++; } +`; + +const MODULE2 = `export let foo = 2;`; + +add_task(async function test_disallowed_import() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy"], + js: ["main.js"], + }, + ], + }, + + files: { + "main.js": async function () { + let disallowedURLs = [ + "data:text/javascript,void 0", + "javascript:void 0", + "http://example.com/script.js", + URL.createObjectURL( + new Blob(["void 0", { type: "text/javascript" }]) + ), + ]; + + for (let url of disallowedURLs) { + await browser.test.assertRejects( + import(url), + /error loading dynamically imported module/, + `should reject import("${url}")` + ); + } + + browser.test.sendMessage("done"); + }, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitMessage("done"); + await extension.unload(); + await contentPage.close(); +}); + +add_task(async function test_normal_import() { + Services.prefs.setBoolPref("extensions.content_web_accessible.enabled", true); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy"], + js: ["main.js"], + }, + ], + }, + + files: { + "main.js": async function () { + /* global exportFunction */ + const url = browser.runtime.getURL("module1.js"); + + await browser.test.assertRejects( + import(url), + /error loading dynamically imported module/, + "Cannot import script that is not web-accessible from page context" + ); + + await browser.test.assertRejects( + window.eval(`import("${url}")`), + /error loading dynamically imported module/, + "Cannot import script that is not web-accessible from page context" + ); + + let promise = new Promise((resolve, reject) => { + exportFunction(resolve, window, { defineAs: "resolve" }); + exportFunction(reject, window, { defineAs: "reject" }); + }); + + window.setTimeout(`import("${url}").then(resolve, reject)`, 0); + + await browser.test.assertRejects( + promise, + /error loading dynamically imported module/, + "Cannot import script that is not web-accessible from page context" + ); + + browser.test.sendMessage("done"); + }, + "module1.js": MODULE1, + "module2.js": MODULE2, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + + await extension.awaitMessage("done"); + + // Web page can not import non-web-accessible files. + await contentPage.spawn([extension.uuid], async uuid => { + let files = ["main.js", "module1.js", "module2.js"]; + + for (let file of files) { + let url = `moz-extension://${uuid}/${file}`; + await Assert.rejects( + content.eval(`import("${url}")`), + /error loading dynamically imported module/, + "Cannot import script that is not web-accessible" + ); + } + }); + + await extension.unload(); + await contentPage.close(); +}); + +add_task(async function test_import_web_accessible() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy"], + js: ["main.js"], + }, + ], + web_accessible_resources: ["module1.js", "module2.js"], + }, + + files: { + "main.js": async function () { + let mod = await import(browser.runtime.getURL("module1.js")); + browser.test.assertEq(mod.bar, 2); + browser.test.assertEq(mod.counter(), 0); + browser.test.sendMessage("done"); + }, + "module1.js": MODULE1, + "module2.js": MODULE2, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitMessage("done"); + + // Web page can import web-accessible files, + // even after WebExtension imported the same files. + await contentPage.spawn([extension.uuid], async uuid => { + let base = `moz-extension://${uuid}`; + + await Assert.rejects( + content.eval(`import("${base}/main.js")`), + /error loading dynamically imported module/, + "Cannot import script that is not web-accessible" + ); + + let promise = content.eval(`import("${base}/module1.js")`); + let mod = (await promise.wrappedJSObject).wrappedJSObject; + Assert.equal(mod.bar, 2, "exported value should match"); + Assert.equal(mod.counter(), 0, "Counter should be fresh"); + Assert.equal(mod.counter(), 1, "Counter should be fresh"); + + promise = content.eval(`import("${base}/module2.js")`); + mod = (await promise.wrappedJSObject).wrappedJSObject; + Assert.equal(mod.foo, 2, "exported value should match"); + }); + + await extension.unload(); + await contentPage.close(); +}); + +add_task(async function test_import_web_accessible_after_page() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy"], + js: ["main.js"], + }, + ], + web_accessible_resources: ["module1.js", "module2.js"], + }, + + files: { + "main.js": async function () { + browser.test.onMessage.addListener(async msg => { + browser.test.assertEq(msg, "import"); + + const url = browser.runtime.getURL("module1.js"); + let mod = await import(url); + browser.test.assertEq(mod.bar, 2); + browser.test.assertEq(mod.counter(), 0, "Counter should be fresh"); + + let promise = window.eval(`import("${url}")`); + let mod2 = (await promise.wrappedJSObject).wrappedJSObject; + browser.test.assertEq( + mod2.counter(), + 2, + "Counter should have been incremented by page" + ); + + browser.test.sendMessage("done"); + }); + browser.test.sendMessage("ready"); + }, + "module1.js": MODULE1, + "module2.js": MODULE2, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitMessage("ready"); + + // The web page imports the web-accessible files first, + // when the WebExtension imports the same file, they should + // not be shared. + await contentPage.spawn([extension.uuid], async uuid => { + let base = `moz-extension://${uuid}`; + + await Assert.rejects( + content.eval(`import("${base}/main.js")`), + /error loading dynamically imported module/, + "Cannot import script that is not web-accessible" + ); + + let promise = content.eval(`import("${base}/module1.js")`); + let mod = (await promise.wrappedJSObject).wrappedJSObject; + Assert.equal(mod.bar, 2, "exported value should match"); + Assert.equal(mod.counter(), 0); + Assert.equal(mod.counter(), 1); + + promise = content.eval(`import("${base}/module2.js")`); + mod = (await promise.wrappedJSObject).wrappedJSObject; + Assert.equal(mod.foo, 2, "exported value should match"); + }); + + extension.sendMessage("import"); + + await extension.awaitMessage("done"); + + await extension.unload(); + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_perf_observers.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_perf_observers.js new file mode 100644 index 0000000000..012c91dc23 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_perf_observers.js @@ -0,0 +1,71 @@ +"use strict"; + +const server = createHttpServer({ + hosts: ["a.example.com", "b.example.com", "c.example.com"], +}); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_perf_observers_cors() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://b.example.com/"], + content_scripts: [ + { + matches: ["http://a.example.com/data/file_sample.html"], + js: ["cs.js"], + }, + ], + }, + files: { + "cs.js"() { + let obs = new window.PerformanceObserver(list => { + list.getEntries().forEach(e => { + browser.test.sendMessage("observed", { + url: e.name, + time: e.connectEnd, + size: e.encodedBodySize, + }); + }); + }); + obs.observe({ entryTypes: ["resource"] }); + + let b = document.createElement("link"); + b.rel = "stylesheet"; + + // Simulate page including a cross-origin resource from b.example.com. + b.wrappedJSObject.href = "http://b.example.com/data/file_download.txt"; + document.head.appendChild(b); + + let c = document.createElement("link"); + c.rel = "stylesheet"; + + // Simulate page including a cross-origin resource from c.example.com. + c.wrappedJSObject.href = "http://c.example.com/data/file_download.txt"; + document.head.appendChild(c); + }, + }, + }); + + let page = await ExtensionTestUtils.loadContentPage( + "http://a.example.com/data/file_sample.html" + ); + await extension.startup(); + + let b = await extension.awaitMessage("observed"); + let c = await extension.awaitMessage("observed"); + + if (b.url.startsWith("http://c.")) { + [c, b] = [b, c]; + } + + ok(b.url.startsWith("http://b."), "Observed resource from b.example.com"); + Assert.greater(b.time, 0, "connectionEnd available from b.example.com"); + equal(b.size, 46, "encodedBodySize available from b.example.com"); + + ok(c.url.startsWith("http://c."), "Observed resource from c.example.com"); + equal(c.time, 0, "connectionEnd == 0 from c.example.com"); + equal(c.size, 0, "encodedBodySize == 0 from c.example.com"); + + await extension.unload(); + await page.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_change.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_change.js new file mode 100644 index 0000000000..842994858e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_change.js @@ -0,0 +1,104 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ hosts: ["example.com", "example.net"] }); +server.registerDirectory("/data/", do_get_file("data")); + +const HOSTS = ["http://example.com/*", "http://example.net/*"]; + +const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +function grantOptional({ extension: ext }, origins) { + return ExtensionPermissions.add(ext.id, { origins, permissions: [] }, ext); +} + +function revokeOptional({ extension: ext }, origins) { + return ExtensionPermissions.remove(ext.id, { origins, permissions: [] }, ext); +} + +function makeExtension(id, content_scripts) { + return ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + + browser_specific_settings: { gecko: { id } }, + content_scripts, + + permissions: ["scripting"], + host_permissions: HOSTS, + }, + files: { + "cs.js"() { + browser.test.log(`${browser.runtime.id} script on ${location.host}`); + browser.test.sendMessage(`${browser.runtime.id}_on_${location.host}`); + }, + }, + background() { + browser.test.onMessage.addListener(async (msg, origins) => { + browser.test.log(`${browser.runtime.id} registering content scripts`); + await browser.scripting.registerContentScripts([ + { + id: "cs1", + persistAcrossSessions: false, + matches: origins, + js: ["cs.js"], + }, + ]); + browser.test.sendMessage("done"); + }); + }, + }); +} + +// Test that content scripts in MV3 enforce origin permissions. +// Test granted optional permissions are available in newly spawned processes. +add_task(async function test_contentscript_mv3_permissions() { + // Alpha lists content scripts in the manifest. + let alpha = makeExtension("alpha@test", [{ matches: HOSTS, js: ["cs.js"] }]); + let beta = makeExtension("beta@test"); + + await grantOptional(alpha, HOSTS); + await grantOptional(beta, ["http://example.net/*"]); + info("Granted initial permissions for both."); + + await alpha.startup(); + await beta.startup(); + + // Beta registers same content scripts using the scripting api. + beta.sendMessage("register", HOSTS); + await beta.awaitMessage("done"); + + // Only Alpha has origin permissions for example.com. + { + let page = await ExtensionTestUtils.loadContentPage( + `http://example.com/data/file_sample.html` + ); + info("Loaded a page from example.com."); + + await alpha.awaitMessage("alpha@test_on_example.com"); + info("Got a message from alpha@test on example.com."); + await page.close(); + } + + await revokeOptional(alpha, ["http://example.net/*"]); + info("Revoked example.net permissions from Alpha."); + + // Now only Beta has origin permissions for example.net. + { + let page = await ExtensionTestUtils.loadContentPage( + `http://example.net/data/file_sample.html` + ); + info("Loaded a page from example.net."); + + await beta.awaitMessage("beta@test_on_example.net"); + info("Got a message from beta@test on example.net."); + await page.close(); + } + + info("Done, unloading Alpha and Beta."); + await beta.unload(); + await alpha.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_fetch.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_fetch.js new file mode 100644 index 0000000000..f9c1b360a0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_fetch.js @@ -0,0 +1,87 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ hosts: ["example.com", "example.net"] }); +server.registerDirectory("/data/", do_get_file("data")); + +const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +function grantOptional({ extension: ext }, origins) { + return ExtensionPermissions.add(ext.id, { origins, permissions: [] }, ext); +} + +function revokeOptional({ extension: ext }, origins) { + return ExtensionPermissions.remove(ext.id, { origins, permissions: [] }, ext); +} + +// Test granted optional permissions work with XHR/fetch in new processes. +add_task( + { + pref_set: [["dom.ipc.keepProcessesAlive.extension", 0]], + }, + async function test_fetch_origin_permissions_change() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + host_permissions: ["http://example.com/*"], + optional_permissions: ["http://example.net/*"], + }, + files: { + "page.js"() { + fetch("http://example.net/data/file_sample.html") + .then(req => req.text()) + .then(text => browser.test.sendMessage("done", { text })) + .catch(e => browser.test.sendMessage("done", { error: e.message })); + }, + "page.html": ``, + }, + }); + + await extension.startup(); + + let osPid; + { + // Grant permissions before extension process exists. + await grantOptional(extension, ["http://example.net/*"]); + + let page = await ExtensionTestUtils.loadContentPage( + extension.extension.baseURI.resolve("page.html") + ); + + let { text } = await extension.awaitMessage("done"); + ok(text.includes("Sample text"), "Can read from granted optional host."); + + osPid = page.browsingContext.currentWindowGlobal.osPid; + await page.close(); + } + + // Release the extension process so that next part starts a new one. + Services.ppmm.releaseCachedProcesses(); + + { + // Revoke permissions and confirm fetch fails. + await revokeOptional(extension, ["http://example.net/*"]); + + let page = await ExtensionTestUtils.loadContentPage( + extension.extension.baseURI.resolve("page.html") + ); + + let { error } = await extension.awaitMessage("done"); + ok(error.includes("NetworkError"), `Expected error: ${error}`); + + if (WebExtensionPolicy.useRemoteWebExtensions) { + notEqual( + osPid, + page.browsingContext.currentWindowGlobal.osPid, + "Second part of the test used a new process." + ); + } + + await page.close(); + } + + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js new file mode 100644 index 0000000000..d775bb2cfb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js @@ -0,0 +1,149 @@ +"use strict"; + +function makeExtension({ id, isPrivileged, withScriptingAPI = false }) { + let permissions = []; + let content_scripts = []; + let background = () => { + browser.test.sendMessage("background-ready"); + }; + + if (isPrivileged) { + permissions.push("mozillaAddons"); + } + + if (withScriptingAPI) { + permissions.push("scripting"); + // When we don't use a content script registered via the manifest, we + // should add the origin as a permission. + permissions.push("resource://foo/file_sample.html"); + + // Redefine background script to dynamically register the content script. + if (isPrivileged) { + background = async () => { + await browser.scripting.registerContentScripts([ + { + id: "content_script", + js: ["content_script.js"], + matches: ["resource://foo/file_sample.html"], + persistAcrossSessions: false, + runAt: "document_start", + }, + ]); + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(1, scripts.length, "expected 1 script"); + + browser.test.sendMessage("background-ready"); + }; + } else { + background = async () => { + await browser.test.assertRejects( + browser.scripting.registerContentScripts([ + { + id: "content_script", + js: ["content_script.js"], + matches: ["resource://foo/file_sample.html"], + persistAcrossSessions: false, + runAt: "document_start", + }, + ]), + /Invalid url pattern: resource:/, + "got expected error" + ); + + browser.test.sendMessage("background-ready"); + }; + } + } else { + content_scripts.push({ + js: ["content_script.js"], + matches: ["resource://foo/file_sample.html"], + run_at: "document_start", + }); + } + + return ExtensionTestUtils.loadExtension({ + isPrivileged, + + manifest: { + manifest_version: 2, + browser_specific_settings: { gecko: { id } }, + content_scripts, + permissions, + }, + + background, + + files: { + "content_script.js"() { + browser.test.assertEq( + "resource://foo/file_sample.html", + document.documentURI, + `Loaded content script into the correct document (extension: ${browser.runtime.id})` + ); + browser.test.sendMessage(`content-script-${browser.runtime.id}`); + }, + }, + }); +} + +const verifyRestrictSchemes = async ({ withScriptingAPI }) => { + let resProto = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + resProto.setSubstitutionWithFlags( + "foo", + Services.io.newFileURI(do_get_file("data")), + resProto.ALLOW_CONTENT_ACCESS + ); + + let unprivileged = makeExtension({ + id: "unprivileged@tests.mozilla.org", + isPrivileged: false, + withScriptingAPI, + }); + let privileged = makeExtension({ + id: "privileged@tests.mozilla.org", + isPrivileged: true, + withScriptingAPI, + }); + + await unprivileged.startup(); + await unprivileged.awaitMessage("background-ready"); + + await privileged.startup(); + await privileged.awaitMessage("background-ready"); + + unprivileged.onMessage( + "content-script-unprivileged@tests.mozilla.org", + () => { + ok( + false, + "Unprivileged extension executed content script on resource URL" + ); + } + ); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `resource://foo/file_sample.html` + ); + + await privileged.awaitMessage("content-script-privileged@tests.mozilla.org"); + + await contentPage.close(); + + await privileged.unload(); + await unprivileged.unload(); +}; + +// Bug 1780507: this only works with MV2 currently because MV3's optional +// permission mechanism lacks `restrictSchemes` flags. +add_task(async function test_contentscript_restrictSchemes_mv2() { + await verifyRestrictSchemes({ withScriptingAPI: false }); +}); + +// Bug 1780507: this only works with MV2 currently because MV3's optional +// permission mechanism lacks `restrictSchemes` flags. +add_task(async function test_contentscript_restrictSchemes_scripting_mv2() { + await verifyRestrictSchemes({ withScriptingAPI: true }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_scriptCreated.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_scriptCreated.js new file mode 100644 index 0000000000..2f10f8f252 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_scriptCreated.js @@ -0,0 +1,61 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +// Test that document_start content scripts don't block script-created +// parsers. +add_task(async function test_contentscript_scriptCreated() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_document_write.html"], + js: ["content_script.js"], + run_at: "document_start", + match_about_blank: true, + all_frames: true, + }, + ], + }, + + files: { + "content_script.js": function () { + if (window === top) { + addEventListener( + "message", + msg => { + browser.test.assertEq( + "ok", + msg.data, + "document.write() succeeded" + ); + browser.test.sendMessage("content-script-done"); + }, + { once: true } + ); + } + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_document_write.html` + ); + + await extension.awaitMessage("content-script-done"); + + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_teardown.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_teardown.js new file mode 100644 index 0000000000..9ec72e6455 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_teardown.js @@ -0,0 +1,101 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_contentscript_reload_and_unload() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["contentscript.js"], + }, + ], + }, + + files: { + "contentscript.js"() { + browser.test.sendMessage("contentscript-run"); + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let events = []; + { + const { Management } = ChromeUtils.importESModule( + "resource://gre/modules/Extension.sys.mjs" + ); + let record = (type, extensionContext) => { + let eventType = type == "proxy-context-load" ? "load" : "unload"; + let url = extensionContext.uri.spec; + let extensionId = extensionContext.extension.id; + events.push({ eventType, url, extensionId }); + }; + + Management.on("proxy-context-load", record); + Management.on("proxy-context-unload", record); + registerCleanupFunction(() => { + Management.off("proxy-context-load", record); + Management.off("proxy-context-unload", record); + }); + } + + const tabUrl = "http://example.com/data/file_sample.html"; + let contentPage = await ExtensionTestUtils.loadContentPage(tabUrl); + + await extension.awaitMessage("contentscript-run"); + + let contextEvents = events.splice(0); + equal( + contextEvents.length, + 1, + "ExtensionContext state change after loading a content script" + ); + equal( + contextEvents[0].eventType, + "load", + "Create ExtensionContext for content script" + ); + equal(contextEvents[0].url, tabUrl, "ExtensionContext URL = page"); + + await contentPage.spawn([], () => { + this.content.location.reload(); + }); + await extension.awaitMessage("contentscript-run"); + + contextEvents = events.splice(0); + equal( + contextEvents.length, + 2, + "ExtensionContext state changes after reloading a content script" + ); + equal(contextEvents[0].eventType, "unload", "Unload old ExtensionContext"); + equal(contextEvents[0].url, tabUrl, "ExtensionContext URL = page"); + equal( + contextEvents[1].eventType, + "load", + "Create new ExtensionContext for content script" + ); + equal(contextEvents[1].url, tabUrl, "ExtensionContext URL = page"); + + await contentPage.close(); + + contextEvents = events.splice(0); + equal( + contextEvents.length, + 1, + "ExtensionContext state change after unloading a content script" + ); + equal( + contextEvents[0].eventType, + "unload", + "Unload ExtensionContext after closing the tab with the content script" + ); + equal(contextEvents[0].url, tabUrl, "ExtensionContext URL = page"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js new file mode 100644 index 0000000000..3b8721ad8d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js @@ -0,0 +1,1385 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/** + * Tests that various types of inline content elements initiate requests + * with the triggering pringipal of the caller that requested the load, + * and that the correct security policies are applied to the resulting + * loads. + */ + +// Make sure media pre-loading is enabled on Android so that our